I'm exploring unstructured Task lifetime and actor retention, and ran into behavior I can't fully explain. I have a reproducible example and a specific question about the mechanism.
Setup
var temperatureStream: AsyncStream<Double> {
AsyncStream {
do {
try await Task.sleep(for: .seconds(3))
return Double.random(in: -40...45)
} catch {
return nil
}
}
}
@MainActor
class TemperatureDisplayView {
deinit {
print("deinit called")
}
func displayWithCapture() async {
Task { [weak self] in
await self?.performOperation1()
}
try? await Task.sleep(for: .seconds(2))
}
func displayWithoutCapture() async {
Task {
await Self.performOperation2()
}
try? await Task.sleep(for: .seconds(2))
}
func performOperation1() async {
for await temperature in temperatureStream {
print("\(#function) - Temperature:", temperature)
}
}
static func performOperation2() async {
for await temperature in temperatureStream {
print("\(#function) - Temperature:", temperature)
}
}
}
@main
struct Main {
static func main() async {
await withTaskGroup(of: Void.self) { group in
group.addTask {
var display: TemperatureDisplayView! = TemperatureDisplayView()
await display.displayWithCapture()
print("DisplayViewWithCapture exited")
display = nil
}
group.addTask {
let display = TemperatureDisplayView()
await display.displayWithoutCapture()
print("DisplayViewWithoutCapture exited")
}
await group.waitForAll()
}
}
}
Observed behavior
Here's a sample of my output:
DisplayViewWithoutCapture exited
DisplayViewWithCapture exited
deinit called â only fires once, for the static case
performOperation1() - Temperature: 11.42
performOperation2() - Temperature: 31.04
...
What I observe:
-
In the static case (
displayWithoutCapture):deinitis called as expected. The instance is released. But the unstructured Task keeps running; confirming that unstructured tasks are owned by the runtime, not their creator. -
In the instance case (
displayWithCapture):deinitis never called even though the closure capturesselfweakly with[weak self].
My question
In the instance case, my hypothesis is that even though self is captured weakly at the closure boundary, calling await self?.performOperation1() causes the runtime to create a strong reference to the actor instance for the duration of that await; and since performOperation1 never returns (infinite async loop), that strong reference is never released, preventing deallocation.
Is this correct? Specifically:
-
Does dispatching an
asyncmethod on a@MainActorinstance via optional chaining (self?.method()) create a strong retain on the actor for the duration of execution, even when the original capture was weak? -
If so, is this documented or specified anywhere in the Swift Evolution proposals or concurrency runtime documentation?
-
Is this a pattern developers should actively watch out for i.e. that
[weak self]alone is insufficient to prevent retention when calling long-running async instance methods?