Unstructured Task prevents deallocation of @MainActor class when calling instance async method via weak reference — expected behavior?

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): deinit is 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): deinit is never called even though the closure captures self weakly 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:

  1. Does dispatching an async method on a @MainActor instance via optional chaining (self?.method()) create a strong retain on the actor for the duration of execution, even when the original capture was weak?

  2. If so, is this documented or specified anywhere in the Swift Evolution proposals or concurrency runtime documentation?

  3. 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?

Yes, though it's not "the runtime", it's just that self?.foo() is shorthand for

if let self {
    self.foo()
}

— if you want to call a method on an object, you ought to have that object alive for the whole duration of the call, whether that method is async or not[1]. In fact, performOperation1 being an async function barely changes anything — you can have a normal infinite synchronous loop keeping an object alive indefinitely as well. That is, these semantics are not specific to Swift Concurrency.

To answer your third question — yes, you might want to watch out for that if deallocation matters for you here. You might want to hoist the loop outside and write something like this:

func displayWithCapture() async {
    Task { [weak self] in
        for await temperature in temperatureStream {
            await self?.performOperationInner(temperature)
        }
    }
}

  1. The compiler can't just randomly decide to break your algorithm by releasing objects mid-function; this is actually more general and is the case for any argument of any function. â†Šī¸Ž

2 Likes

(post deleted by author)

Thank you @nkblov, that makes a lot of sense.

I had incorrectly assumed that an object's lifetime was governed solely by strong references; that once all strong references are gone, the object is deallocated. I hadn't considered that calling a method on an object, async or not, inherently requires the object to stay alive for the duration of that call. The if let self makes that immediately obvious in hindsight.

While the hoisting suggestion is an appropriate fix in the simplified example I shared, hoisting the loop out of performOperation1 may not always be straightforward; the method could be several lines of logic that also need to be independently unit testable. But it's definitely a pattern I'll need to watch out for, especially with long-running async methods.

Appreciate the clear explanation.

An object's lifetime is governed by strong refrerences, it's just that calling a method on it promotes a weak reference to a strong one (that's what the if let self part does).

1 Like