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)