Is `[weak self]` actually necessary in a `Task` closure?

I keep running into the same review comment on team PRs: "add [weak self] to that Task."

Sometimes I agree, most of the time I don't, and I've never been able to point at a rule that settles it.

I'd like to check my mental model against people who actually know the runtime.

What I think is true

Task { } takes an @escaping closure, so self is captured strongly β€” no argument there. But unlike a closure stored as a property, the capture is temporary: once the task's body finishes, the async frame (and everything it captured) is torn down and self is released.

final class ViewModel {
    func load() {
        Task {
            let items = try? await service.fetchItems()  // strong self
            self.items = items ?? []
        }                                                 // self released here?
    }
}

If that's accurate, then for a bounded task [weak self] doesn't change whether self gets deallocated, only when. Which makes it an optimization about deallocation timing, not a leak fix β€” and worth a very different conversation in code review.

The cases where I'm fairly confident it does matter:

1. Tasks that don't reliably end.

Task {
    for await event in self.stream {   // self alive as long as the stream is
        self.handle(event)
    }
}

2. A task stored on self and cancelled in deinit.

final class Controller {
    private var task: Task<Void, Never>?

    func start() {
        task = Task { await self.run() }   // self β†’ task β†’ self
    }

    deinit { task?.cancel() }             // never runs, so cancel never happens
}

This one feels genuinely broken, and `[weak self]` is the usual fix β€” though it seems more like a signal that the ownership design is wrong than a capture-list problem.

Happy to be told my model is wrong β€” that's mostly why I'm posting. And if this has been settled somewhere I failed to search for, a link is a perfectly good answer.

Quoting @eskimo from a post here:

Task has pretty much the same rules as any other API that takes a closure: You only have to do the weak-self dance if the task can run indefinitely.

Also: I seem to recall (not sure if this is still the case?) that capturing self weakly inside of a Task closure alters the isolation of the closure, as Swift's isolation model does not allow for isolation to dynamically change during the execution of a function.

This is correct in the case that self is the "isolation source" for the context in which the Task is formed. E.g. if you're within an actor instance method, it works like this:

actor A {
  var state = 0

  func doit() async {
    let t1 = Task {
      self.state += 1 // βœ… – strong capture of `self` ensures task is self-isolated
      self.assertIsolated() // βœ…
    }

    let t2 = Task { [weak self] in
        self?.state += 1 // πŸ›‘ – weak capture of `self` makes closure non-isolated
        self?.assertIsolated() // πŸ’₯
    }

    _ = await (t1.value, t2.value)
  }
}

But if the isolation source is from a global actor, then a weak capture does not affect the closure's isolation, because it is the shared global actor instance, and not self, that will be used by the closure so it runs on the proper executor:

@MainActor
final class C {
    var state = 0

    func doit() async {
        let t1 = Task {
            state += 1 // βœ… – closure captures shared main actor instance implicitly
            MainActor.assertIsolated() // βœ…
        }

        let t2 = Task { [weak self] in
            self?.state += 1 // βœ… – even if self is weakly captured
            MainActor.assertIsolated() // βœ…
        }
        
        _ = await (t1.value, t2.value)
    }
}
And for completion...

There's one more case which has quirky behavior, and that's when you capture an isolated parameter explicitly in a capture list. In this case, even if it's a strong capture, the referenced value will no longer be treated as a "source" for isolating the closure, which leads to this confusing behavior:

func confusing(
    isolation: isolated (any Actor) = #isolation
) async {
    let t1 = Task {
        isolation.assertIsolated() // βœ… – capture without capture list preserves isolation
    }

    let t2 = Task { [isolation] in
        isolation.assertIsolated() // πŸ’₯ – capture via capture list loses it
    }

    _ = await (t1.value, t2.value)
}
8 Likes

IMO, capturing self in an an async closure is almost always incorrect.

Let's start with [weak self] specificially though: it's often misleading, and rarely robust to refactoring.

For example, if you have this:

final class C: Sendable {
    func doSomething() {
        Task { [weak self] in
            await self?.doSomethingAsync()
        }
    }
}

Then self is made strong during the call to doSomethingAsync, so if doSomethingAsync is long-running, [weak self] isn't preventing a cycle while it runs.

Another example:

final class C: Sendable {
    func doSomething() {
        Task { [weak self] in
            guard let self else { return }
            let a = await someService.doSomethingAsync()
            let b = await someOtherService.doSomethingElseAsync()
            self.property = self.process(a, b)
        }
    }
}

Then self is made strong as soon as the Task begins executing, so [weak self] isn't preventing a cycle while any of the async code actually runs.

Note that although these lifetime extensions aren't "leaks" (the cycle is broken when the task exits), they are potentially dramatically extending the lifetime of self, particularly compared to callback-based asynchronous code they might be replacing.

To get [weak self] right in an async closure, you have to

  • avoid guard let self except in synchronous scopes (e.g. do { guard let self else { return }; self.syncMethod(); } is fine, just make sure self isn't strong across an await)
  • avoid async methods on self (because they always violate the first principle)
  • avoid passing self to async methods (same reason)
  • somehow enforce that on all future refactorings, even though those are things that people will really want to do, and that look completely natural at code review time…

So, no [weak self] then; what about strong [self]?

Well, if you avoid [weak self] then you're still in the same trouble [weak self] was trying to avoid: self is strongly retained for the duration of the task.

Since we've got a self to capture, we're in a class, and we've established we have a long-running task to manage. We probably want to be able to cancel it, so we might write:

final class C: Sendable {
    let task = Mutex<Task?>(nil)
    deinit {
        task.withLock { $0.take() }?.cancel()
    }
    func doSomething() {
        task.withLock { $0.take() }?.cancel()
        let t = Task { ... }
        task.withLock { $0 = t }
    }
}

But that deinit is misleading if the task strongly retains self (whether deliberately or through one of the [weak self] pitfalls above): deinit won't run until the task exits, so the cancel() in deinit doesn't help to end the task at the natural lifetime of self.


What's the answer then? The principle I've come to is:

  • async closures must not have weak captures (because even if you get it right now, it'll be wrong when refactored)
  • reference types must not capture [self] in async closures (because you're inevitably creating a cycle whilst the task executes)
  • that implies that tasks must be managed "at a higher level", ideally the application framework β€” you shouldn't have to manage tasks, you should just be able to use structured concurrency, creating async functions wherever they make sense, and dropping out to synchronous code only when you want to.
    • Hummingbird does this well
    • SwiftUI's .task modifier is helpful but not in and of itself sufficient
    • Anywhere else, you're completely on your own.
  • When implementing task management to address an application framework shortcoming, all Task handles should be held for an appropriate lifespan (e.g. a UIViewController's lifetime) and cancelled explicitly. They should be created as "high up" as is reasonable, to maximize how much structured concurrency you can write.
    • I'll make an exception and allow discarding Task handles for the equivalent of DispatchQueue.async, where the Task body is synchronous, though I wish the stdlib would assist in enforcing this. It can still cause real problems to extend the lifetime even "just for the actor hop", but it's not worse than DispatchQueue.async already was.

(I've also more or less come to the conclusion that reference types should not be public or internal β€” they're fine as a private/fileprivate implementation detail of a struct where their reference count can be carefully constrained, but as soon as you've got people retaining things willy-nilly it's very easy to end up in difficulties. FWIW, the stdlib mostly obeys this principle & the exceptions are limited, e.g. KeyPath is immutable)

5 Likes

You may schedule a task and have self deallocated before the task runs, but only if the task does not strongly capture self. So yes, there is a slight difference, though it probably will not matter in practice.


BTW, it's a similar difference here:

    DispatchQueue.main.asyncAfter(deadline: .now() + 100) {
        [weak self] in
        // self could be gone here
    }

vs

    try await Task.sleep(for: .seconds(100))
    // self could not be gone here

but in this case it might matter in practice.

Thanks you all for this detailed explanation

But to make sure I understand correctly self will always be captured as a strong refrence when Task begins executing, right ? And the only way to prevent this is to handle task cancellation

When you use a weak capture, then the task has no strong reference.

As soon as you do something like

guard let self else { … }

then the rest of that block now has a strong reference (self can no longer be nil, so it cannot be weak anymore). And if that rest of the block is running indefinitely, then you can get a retain cycle.

1 Like

Thank you for expanding on this! I've actually stumbled upon this little foot-gun with a global actor (inside of a @MainActor UI class), but perhaps either:

  • This was fixed at some point.
  • Annotating the unstructured Task operation closure with sending made this a non-issue.

Either way, I'm no longer able to reproduce this via toying around with @MainActor annotated classes.