Subtle retain cycle is subtle

Retain 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.