Subtle retain cycle is subtle
19 Jun 2025Retain cycles are often a pain to track down and here’s an example of a less obvious one we had recently.
The problem
Here’s a simplified reproduction of the issue
class Example {
var task: Task<Void, Never>?
init() {
task = Task { [weak self] in
guard let self else { return }
repeat {
performSomeWork()
} while !Task.isCancelled
}
}
func performSomeWork() { }
deinit {
print("deinit")
task?.cancel()
}
}
Let’s not focus too much on the exact code as it doesn’t do anything except illustrate the issue.
When running this code the deinit
will never run because the Task
is creating a retain cycle keeping Example
alive indefinitely.
At first glance this looks like fairly standard code - the part of interest is
task = Task { [weak self] in
guard let self else { return }
repeat {
performSomeWork()
} while !Task.isCancelled
}
In the above we see the common weak/strong dance that we are all used to but we still have a cycle so what gives?
We are spinning a loop in the task that only stops when the task is cancelled.
The only place we currently call cancel is in the deinit
of the Example
class so this loop is partly responsible for the cycle.
The key thing to look for is who is taking a strong reference and what is the scope of that reference?
task = Task { [weak self] in //
guard let self else { return } // - strong reference taken here
//
repeat { //
performSomeWork() //
} while !Task.isCancelled //
} // - goes out of scope here
The problem we have looking at the scope is that the strong reference is in scope until the end of the function, but we have our repeat
loop before the end of the function so we will never get to the end.
Breaking the cycle
There’s many ways to break the cycle - let’s look at a few
Change the scope of the strong reference
task = Task { [weak self] in //
repeat { //
guard let self else { return } // - strong reference taken here
performSomeWork() //
} while !Task.isCancelled // - goes out of scope here
} //
If we move the guard
inside the repeat
then it will only take a strong reference for the body of repeat.
This means that the strong reference is released and retaken each time through the loop.
Due to the guard being evaluated fresh each time this allows the cycle to be broken.
Use the weak reference everywhere
task = Task { [weak self] in
repeat {
self?.performSomeWork()
} while !Task.isCancelled
}
In this example it looks pretty clean to do this but in real code you might not be able to have the nullability in which case you’d end up using guard
or if let
to unwrap things (just be careful on scope).
Manually break the cycle
task?.cancel()
For this you’d have to have some other code get a reference to the task and call cancel()
at the appropriate time.
Be careful
Another thing you might try to break the cycle is using the capture groups.
task = Task { [performSomeWork] in
repeat {
performSomeWork()
} while !Task.isCancelled
}
For this example we are back to retain cycle city.
The issue is instance methods have an implicit reference to self
so this won’t do the job.
The capture group would indeed work if we are getting a reference to something that doesn’t have a self
reference for example instance properties.
You could write a unit to verify that the reference does not leak something like this.
In this example though you’d need to add a short delay before you set the system under test to nil
to ensure that the Task
has had enough time to start working and take any strong reference you want to validate is held correctly.
Conclusion
Retain cycles are a pain and the ol’ chuck a weak
on things doesn’t always work so it’s worth writing tests and using instruments to hunt things down.