
Discover page available: Concurrency
Managing an app’s memory is something that tends to be especially tricky to do within the context of asynchronous code, as various objects and values often need to be captured and retained over time in order for our asynchronous calls to be performed and handled.
While Swift’s relatively new async/await syntax does make many kinds of asynchronous operations easier to write, it still requires us to be quite careful when it comes to managing the memory for the various tasks and objects that are involved in such asynchronous code.
One interesting aspect of async/await (and the Task type that we need to use to wrap such code when calling it from a synchronous context) is how objects and values often end up being implicitly captured while our asynchronous code is being executed.
For example, let’s say that we’re working on a DocumentViewController, which downloads and displays a Document that was downloaded from a given URL. To make our download execute lazily when our view controller is about to be displayed to the user, we’re starting that operation within our view controller’s viewWillAppear method, and we’re then either rendering the downloaded document once available, or showing any error that was encountered — like this:
class DocumentViewController: UIViewController {
private let documentURL: URL
private let urlSession: URLSession
...
override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
Task {
do {
let (data, _) = try await urlSession.data(from: documentURL)
let decoder = JSONDecoder()
let document = try decoder.decode(Document.self, from: data)
renderDocument(document)
} catch {
showErrorView(for: error)
}
}
}
private func renderDocument(_ document: Document) {
...
}
private func showErrorView(for error: Error) {
...
}
}
Now, if we just quickly look at the above code, it might not seem like there’s any object capturing going on whatsoever. After all, asynchronous capturing has traditionally only happened within escaping closures, which in turn require us to always explicitly refer to self whenever we’re accessing a local property or method within such a closure (when self refers to a class instance, that is).
So we might expect that if we start displaying our DocumentViewController, but then navigate away from it before its download has completed, that it’ll be successfully deallocated once no external code (such as its parent UINavigationController) maintains a strong reference to it. But that’s actually not the case.
That’s because of the aforementioned implicit capturing that happens whenever we create a Task, or use await to wait for the result of an asynchronous call. Any object used within a Task will automatically be retained until that task has finished (or failed), including self whenever we’re referencing any of its members, like we’re doing above.
In many cases, this behavior might not actually be a problem, and will likely not lead to any actual memory leaks, since all captured objects will eventually be released once their capturing task has completed. However, let’s say that we’re expecting the documents downloaded by our DocumentViewController to potentially be quite large, and that we wouldn’t want multiple view controllers (and their download operations) to remain in memory if the user quickly navigates between different screens.
The classic way to address this sort of problem would be to perform a weak self capture, which is often accompanied by a guard let self expression within the capturing closure itself — in order to turn that weak reference into a strong one that can then be used within the closure’s code:
class DocumentViewController: UIViewController {
...
override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
Task { [weak self] in
guard let self = self else { return }
do {
let (data, _) = try await self.urlSession.data(
from: self.documentURL
)
let decoder = JSONDecoder()
let document = try decoder.decode(Document.self, from: data)
self.renderDocument(document)
} catch {
self.showErrorView(for: error)
}
}
}
...
}
Unfortunately, that’s not going to work in this case, as our local self reference will still be retained while our asynchronous URLSession call is suspended, and until all of our closure’s code has finished running (just like how a local variable within a function is retained until that scope has been exited).
So if we truly wanted to capture self weakly, then we’d have to consistently use that weak self reference throughout our closure. To make it somewhat simpler to use our urlSession and documentURL properties, we could capture those separately, as doing so won’t prevent our view controller itself from being deallocated:
class DocumentViewController: UIViewController {
...
override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
Task { [weak self, urlSession, documentURL] in
do {
let (data, _) = try await urlSession.data(from: documentURL)
let decoder = JSONDecoder()
let document = try decoder.decode(Document.self, from: data)
self?.renderDocument(document)
} catch {
self?.showErrorView(for: error)
}
}
}
...
}
The good news is that, with the above in place, our view controller will now be successfully deallocated if it ends up being dismissed before its download has completed.
However, that doesn’t mean that its task will automatically be cancelled. That might not be a problem in this particular case, but if our network call resulted in some kind of side-effect (like a database update), then that code would still run even after our view controller would be deallocated, which could result in bugs or unexpected behavior.