Async/Await in Swift – Rethinking Callbacks and Escaping the Pyramid of Doom
Asynchronous functions are on their way to Swift, bringing its concurrency facilities up to par with Kotlin and JavaScript.
Asynchronous functions are on their way to Swift, bringing its concurrency facilities up to par with Kotlin and JavaScript.
A major development effort is currently underway to add async functions and the supporting infrastructure to the Swift programming language. When they are released, they will offer a more modern and reliable way of writing asynchronous code.
Additionally, with async part of the language, it becomes possible to think in the same terms across a whole mobile service stack: Swift async/await for iOS, Kotlin coroutines for Android, and JavaScript async/await for web content and Node.js components.
It will be a while until async/await can be used in an iOS application released on the app store. In the meantime, however, we can prepare ourselves for the future. Let’s take a look at the design currently being discussed in the Swift evolution forums and try out some code using a development snapshot of the Swift toolchain!
What is async
The traditional way of programming asynchronous operations has been by callbacks:
getImage(at: url) { image in
self.use(image: image)
}
Any major iOS application contains many instances of such patterns. What async functions do is allow you to write the following and get the same behavior:
let image = await getImage(at: url)
use(image: image)
See that await there? That is a reminder to you, the programmer, that the function is split at this line. Note that this is in contrast to how await is a functional keyword in C#. In Swift, async is a marker similar to try; you cannot call an async function without await, any more than you can call a throws function without try.
The compiler effectively transforms the latter code into the former, and you have to take care that the state of things has not changed from under you when the function resumes executing on the next line. But this is no different from being mindful of the application state when writing callbacks.
To use await, the function itself must be marked async:
func getStuffDone() async {
// can use await here
}
This, of course, presents the question of how to call into async functions in the first place. All of your current code is non-async, after all. Hold on to that thought; we’ll get there.
Why is async
The above may not seem too dramatic, to be honest. Is this really worth adding new and exotic syntax to the language? However, the benefits of this style become more apparent as your asynchronous processing gets more complicated. Take this (exaggerated) example:
getData(at: url) { data in
self.parse(data: data) { parsedData in
self.getRelatedData(at: parsedData.relatedUrl) { relatedData in
self.getImage(at: relatedData.imageUrl) { image in
self.process(image: image) { processedImage in
self.use(image: processedImage)
}
}
}
}
}
This is the pyramid of doom, named after the shape the lines of code start to resemble at increasing levels of nesting. You can escape the pyramid, to a degree, by factoring out your operation to multiple functions, with the caveat that capturing variables in the context of the initial call gets more cumbersome. It also becomes more difficult to get an overview of the operation at a high level.
Writing the above in terms of async functions begins to reveal the power of this new way of expressing asynchrony:
let data = await getData(at: url)
let parsedData = await parse(data: data)
let relatedData = await getRelatedData(at: parsedData.relatedUrl)
let image = await getImage(at: relatedData.imageUrl)
let processedImage = await process(image: image)
use(image: processedImage)
The pyramid is gone, and instead we have a neat progression of immutable assignments, perhaps the easiest kind of code to read in the world.
Even more powerful is the ability to easily write control flow that involves the conditional execution of asynchronous operations:
let thingToUse: Thing
if thing.needsProcessing {
thingToUse = await process(thing: thing)
} else {
thingToUse = thing
}
let usageResult = await use(thing: thing)
handle(usageResult: usageResult, of: thing)
It is, of course, possible to write the same flow using callbacks. Let’s take a look:
let continuation: (Thing) -> Void = { thing in
self.use(thing: thing) { usageResult in
self.handle(usageResult: usageResult, of: thing)
}
}
if thing.needsProcessing {
process(thing: thing) { processedThing in
continuation(processedThing)
}
} else {
continuation(thing)
}
The code looks all over the place; however, this is the way to write it without repetition. The end part of the process comes first in the code, and you need multiple read-throughs to understand what is going on.
Also, you must manually ensure that continuation is called on every execution path, or else your process will end up unceremoniously in the bit bucket. I believe it is clear which style is more prone to mistakes.
Naturally, all other Swift control structures, like loops and exceptions, are also available:
while let part = try await getNextPart() {
try await process(part: part)
}
I am not even going to write that in terms of callbacks. It comes out to many lines of code, spread across multiple functions.
How is async
Asynchronous functions and the await transformation are not yet part of the Swift language. They are, however, in active development, and the adventurous can preview the feature by installing a development snapshot toolchain.
The development effort is also accompanied by active discussion on the Swift evolution forum. The design has not been finalized at the time of writing, and code written for the currently available preview probably will not work when the feature appears in an official release of Swift. That said, the high-level concepts should remain applicable. With this in mind, let’s take a look at how async can be used in Swift right now.
As noted in the beginning, async functions can only be called from other async functions. This presents a problem: all the existing Swift code is non-async. Well, if you happen to be writing a command line program or script, you can simply make awaiting calls from your top-level code.
Note: Neither this nor the following async @main work in the snapshot toolchain I used to test async, but this is the currently proposed design. To actually test async top-level code at the moment, you need to use the runAsyncAndBlock function. This note will be deleted when top-level async code becomes available in the snapshot version.
// main.swift
await longOperation()
thisRunsAfterLongOperationIsDone()
In a similar fashion, you can make the main function of your @main type async, enabling awaiting calls.
@main
struct MyProgram {
static func main() async {
// await things as usual
}
}
Now, neither of these is likely to be useful when writing an iOS application. What is a humble mobile developer to do? To answer that, we need to take a preview of another part of the in-development concurrency system: Tasks and Structured Concurrency.
The basic idea is that all async function invocations are running as part of a Task, and a given Task may be either actively executing code or awaiting the result of an async function call. Parallelism can be introduced by launching child Tasks and later awaiting the results of those tasks.
Now here is the Structured part: child Tasks must finish before their parent. This ensures that no subpart of an asynchronous operation is left running after the root operation finishes. This, in turn, makes it easier to figure out the possible states of your program.
All calls to async functions must be inside a Task, so let’s start one:
let task = Task.runDetached {
let text = await getText()
self.use(text: text)
}
Task.runDetached starts a new Task and returns a handle to that Task. The handle can be used to cancel the Task. It is not necessary to hold on to the handle, but it is good practice to do so anyway: outstanding Tasks are generally noticeable to users, so they should be part of your model.
I believe that the API will gently push developers toward better application architectures here, as it used to be all too easy to fire off a URL request or whatnot and then just forget about it. You could still do that, but the compiler will warn you, and you will have to consciously discard the handle to get rid of the warning.
self.updateTask?.cancel()
self.updateTask = Task.runDetached {
await self.update()
}
_ = Task.runDetached {
await iDoNotCareAboutThisTask()
}
Task.runDetached does not start a scoped child task when called inside a Task. To start a child task, you use a new kind of assignment instead, async let:
func getPartOne async -> PartOne { … }
func getPartTwo async -> PartTwo { … }
func combineData() async {
async let partOne = getPartOne()
async let partTwo = getPartTwo()
await use(partOne: partOne, partTwo: partTwo)
// at this line getPartOne and getPartTwo must have finished
}
Note that you do not await when starting a child task (the async let statement), even though the assignment is calling an async function. This allows you to continue executing code that is independent of the result of the child task. Later, when you do need the result, you must use await.
When you have awaited an async let, you can be sure that the async function call from that async let has finished executing. Also note that, just as try, await spans the entire statement, you do not need to write await separately for each async let used in a single statement.
awaiting a Lightweight Future
Asynchronous functions for Swift are still a little ways off; you will need to keep writing those callbacks in production code for now. We already know that async and await will not be a part of Swift 5.4, and even that is not yet included in the Xcode beta version.
I will be keeping a close eye on the development of async for Swift and hope to explore a more finalized design in a future article. In the meantime, you can play around with the development snapshot toolchain (the async features are hidden behind the compiler option -Xfrontend -enable-experimental-concurrency), but keep in mind that not everything is implemented yet and things will change from their current state in the snapshot build.