Use Task with AsyncStream and avoid retain cycle
I’ve been playing with the Swift 5.5 concurrency feature as it finally compiles for iOS 13 in Xcode 13.2. It means it’s now viable to use for most production code & app that need to support more than only iOS 15.
When using Task to make async code works in non async code, you have to keep in mind that a Task guarantee the execution of the code inside. It means that the Task will retain everything within the Task. I was probably a bit to eager to play with async / await and at first I didn’t properly read Task documentation.
It provides some important information
A task runs regardless of whether you keep a reference to it. However, if you discard the reference to a task, you give up the ability to wait for that task’s result or cancel the task.
The UIKit world is a bit harsher than the SwiftUI world. Task created using the .task modifier in SwiftUI will automatically get cancelled once the view disapear. Not in UIKit, as we don’t have this tighly coupled language / view lifecycle mechanism.
In the following example, I’ll setup a long running job that return an AsyncStream that some UI code can work with. In our case we’ll use it directly in the UIViewController, it’s a good setup for the purpose of this example.
First, let’s setup a worker:
It have a
getSteps() function returning an
AsyncStream make it very easy to build an
AsyncStream from synchronous based code. As you can notice, we also span a
Task, the purpose of this
Task is to be able to use the new
Task.sleep so we can wait some time between steps. We want to simulate a long running job, and we don’t want the
AsyncStream iteration to complete instantly when you’ll start to iterate on it from the outside.
Let’s now setup our
UIViewController which will use our
We start the work in the
init, we want to iterate over all the steps of
AsyncStream returned by our
worker. As it’s async code, we need to wrap it in a
Task. As it’s a long runing task, we need to keep a reference to it, as Apple documentation is stating, we’re responsible for cancelling the
Task. So it makes sense to keep a reference to it and
cancel it int the
If you take another look at our
Worker class, you’ll notice that we connect the
onTermination callback of the
AsyncStream continuation. We’ll receive this callback when the iteration ont the
AsyncStream will be externally terminated or cancelled. So here we’re simply forwarding
Task cancellation so we can cancel our inner
Now as all this code run in an Xcode playground, let’s make it all run and see how does it works, here is the final piece of code, the
App that make it all works together.
Our goal is to test if our
task is actually getting cancelled and all our code deinit properly.
So now let’s run the playground and see what is printed in the console
As we can see, it’s not doing what we would except the code to do. Our
App dismiss our
UIViewController and set its reference to nil, but the deinit of our controller is not getting called and our tasks are never getting cancelled.
Obviously, we have a retain cycle, and it’s super obvious right? The closure of our
Task init is retaining
self as we call
(self.)worker.getSteps() within the
So let’s add a
weak self and try again:
Let’s see the results printed in the console. Same….
At this point, we start to enter the not so obvious quirks of long running task in non async code. The truth is, wether it’s a long running
Task or a one shot short running
Task (just an await call to an async returning function), the release of owner of the
Task will not be done until the
Task is finished or cancelled (and so release all owned code). This is the case because we use
self within the
Task. So here, our controller would eventually get its deinit called once our
Task end. But this is probably not what you want, right?
You could also have no reference to the
task you create and don’t reference self within the task, then it would deinit our controller properly, but the
Task would continue to execute until its end, as it doesn’t die with the controller.
We have two ways of fixing this, if you need a reference to
self before entering the await loop, you can create your needed variable outside of the Task. Like so:
Now the console will look like this:
The controller is properly deinit and the
Task is properly cancelled as the console will not print steps anymore.
Now if you would need to use
self within the loop, you need to do it a little bit different. Basically, you’ll need need to use
[weak self], and if you want to use a non optional self, you’ll need to create your local self strong variable within the loop, not above it or you’ll end up with the same retain cycle.
Now why is that?
So in order to write this article, you have to understand the timeline I’m in. I wrote the code above and created a retain cycle, I quickly found the workaround by playing around, but I didn’t really understood why I needed do it like so. As
Task init closure is
@escaping, you actually don’t need an explicit self, but it’ll still be captured and retained. An again, the Task will actually release everything once it ends. So for most “fire & forget” Task that will wrap just one async function in your
UIViewController to mutate self, you’ll not notice the problem. Sure, your controller will probably end up being dealocated a bit later if you dismiss it before the
Task is ended. And you should be aware that the Task is not cancelled if you dismiss it, so it’s important to keep a reference and cancel it yourself if the Task is not relevant anymore once your UI is dismissed.
So I’ve been looking around, and found this thread which put me on the right track:
Task async retain cycles
A subtlety of async functions is that the executing Task retains the function and its dependencies - this does make…
Then I’ve asked Twitter
and got a few interesting replies
So yes, the answer is that you need to keep a weak reference of self within the loop, as each new step would retain
self as you use it within each loop.
I don’t know how you feel about all this? But it finally make sense in my head and I’m actually getting confortable with plugging long running task to non async code. The new concurency system is really good, and make a lot of code far more easier to read and much more natural, as it’s just Swift with some more keywords.
Maybe this was already obvious to you, maybe not, but to me it was definitely not something obvious from the get go. But now you know, if you’re using a long running AsyncStream wrapped in a Task, how to not retain the whole thing until the Task end. (And again, it could be what you want, but I bet that in most cases, it’s not).
Thanks for reading and happy coding! 🚀