Use Task with AsyncStream and avoid retain cycle

Thomas Ricouard
7 min readDec 26, 2021

--

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:

class Worker {
var task: Task<(), Never>?

func getSteps() -> AsyncStream<Int> {
return .init { continutation in
continutation.onTermination = { @Sendable _ in
self.task?.cancel()
}
task = Task {
var step: Int = 0
for _ in 0..<1000 {
continutation.yield(step)
try? await Task.sleep(nanoseconds: 100000000)
step += 1
}
}
}
}
}

It have a getSteps() function returning an AsyncStream of Int. The init of 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 Worker

class TaskViewController: UIViewController {
let worker = Worker()
var task: Task<(), Never>?

init() {
super.init(nibName: nil, bundle: nil)
doWork()
}

required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}

func doWork() {
print("Task began")
task = Task {
for await step in worker.getSteps() {
print(step)
}
}
}

deinit {
task?.cancel()
print("deinit called")
}
}

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

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

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.

class App {
var controller: TaskViewController?

init() {
present()
DispatchQueue.main.async {
self.dismiss()
}
}

func present() {
print("Present controller")
controller = TaskViewController()
controller?.task
}

func dismiss() {
print("Dismiss controller")
controller = nil
}
}

let app = App()

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 Task

So let’s add a weak self and try again:


func doWork() {
print("Task began")
task = Task { [weak self] in
guard let self = self else { return }
for await step in self.worker.getSteps() {
print(step)
}
}
}

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:

func doWork() {
print("Task began")

let steps = worker.getSteps()
task = Task {
for await step in steps {
print(step)
}
}
}

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.

 func doWork() {
print("Task began")

let steps = worker.getSteps()
task = Task { [weak self] in
for await step in steps {
guard let self = self else { return }
self.currentStep = step
print(self.currentStep)
}
}
}

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:

Then I’ve asked Twitter

and got a few interesting replies

Thanks Troy! This was really the highlight if my whole quest!

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! 🚀

--

--

Thomas Ricouard
Thomas Ricouard

Written by Thomas Ricouard

📱 🚀 🇫🇷 [Entrepreneur, iOS/Mac & Web dev] | Now @Medium, @Glose 📖| Past @google 🔍 | Co-founded few companies before, a movies 🎥 app and smart browser one.