Can a Task cause a retain cycle, and how do you avoid it?
Predict first
What does this print with a strong capture?
final class Job {
init() { Task { try? await Task.sleep(for: .seconds(60)); self.go() } }
func go() {}
deinit { print("deinit") }
}
var j: Job? = Job()
j = nil
print("after")
- deinit, after
- after
Predict first
What does this print with [weak self]?
final class Job {
init() { Task { [weak self] in try? await Task.sleep(for: .seconds(60)); self?.go() } }
func go() {}
deinit { print("deinit") }
}
var j: Job? = Job()
j = nil
print("after")
- deinit, after
- after
Mental model. A contractor you hired for the whole afternoon keeps your front door key until they finish. If the job never ends, neither does the key.
An unstructured Task { … } that uses self captures it strongly for as long as the task runs. For a short task that is fine: it finishes and releases. For a long-lived or endless one (for await over a stream, a polling loop, a long sleep) the task keeps the object alive, and if the object also owns the task you have a cycle. The cancellation you wrote in deinit never executes, because deinit is exactly what the task is preventing.
Capture [weak self] for work that should not extend the object’s life, and cancel from an explicit lifecycle point (stop(), a view’s disappearance). Prefer tasks tied to a lifetime for you: SwiftUI’s .task modifier is cancelled automatically when the view goes away, and structured concurrency (async let, task groups) ends with its scope. A weak capture does not stop the task: it still runs and simply does nothing once self is gone, so cancel it too.
The sleeping task holds the job for its 60 seconds, so clearing the variable does not free it.
The task no longer owns the job, so it frees at once. The task itself keeps sleeping unless you cancel it.
Common traps
- Cancelling the task in
deinitwhile it holdsselfstrongly. Thatdeinitcan never run. - Relying on
[weak self]to stop the work. It lets the object go, but the task keeps running unless cancelled.
They might follow up with
- How does SwiftUI’s
.taskavoid this? It ties the task to the view’s lifetime and cancels it on disappearance. - Is
Task { self.go() }a cycle if the task is not stored? Not a cycle, but it still extends the object’s life until the task ends.
Related questions
- 3.3.5 How do Combine subscriptions create cycles, and why is assign(to:on:) a trap?
- 3.3.4 Why does a Timer keep your object alive, and how do you stop it?
← How do Combine subscriptions create cycles, and why is assign(to:on:)…