iOS
Swift 5.5: Replacing GCD With Async/Await
Matt Neuburg
Written on June 18, 2021

Multithreading! The mere word sends shivers up one’s spine. And if it doesn’t, it should.
Main thread and background threads. Code that runs asynchronously. Code that can run simultaneously with other code. Code that can run simultaneously with itself. Code that can share data across threads — possibly with disastrous consequences. Concurrency.
Multithreaded code is scary stuff. Subtle mistakes can have far-reaching consequences — consequences which might not even manifest themselves on all devices, or at all times. Bugs in your multithreaded code can be notoriously hard to track down; there might be a crash at a certain line of code, but the actual mistake may have happened somewhere else entirely.
Still, sometimes multithreading is inevitable. And for years, one of the main ways we’ve coped is with GCD (Grand Central Dispatch).
GCD is very brilliant in its way, and learning to use it well is a source of pride. But it’s clumsy too. One reason is that GCD is written in C (using Apple’s peculiar brand of C with function blocks). Apple disguises that fact in Swift with some slick renamification, but the mapping between Swift calls and C calls is direct, and the syntax is imposed directly by the underlying C APIs. Complex concurrency is tricky to express in GCD (dispatch groups, anyone?). What’s more, GCD can’t protect you from your own mistakes. You can create a race condition. You can create a thread explosion. GCD won’t stop you.
The most exciting and far-reaching announcement to come out of Apple’s WWDC 2021 is that there’s a whole new way of doing concurrency in Swift, starting in Swift 5.5. Overall, this new way is called structured concurrency. But most people call it by a shorthand title: async/await
.
That name comes from a prominent, powerful language feature. The async/await
language construct itself has a long history, going back (according to the Wikipedia article) at least to the C# language in 2011. It lies at the heart of Swift’s structured concurrency. And it lets you control and express multithreading in a cleaner and safer way than GCD.
Unlike GCD, async/await
consists of pure Swift language features; this means that the compiler can help us use it properly. The resulting code is clear and easy to reason about, which is crucial when you’re doing something as tricky as multithreading.
Starting with the Xcode 13 beta, async/await
is ready for beta testers to try out. Not all proposed features are in place as of this writing, and it’s possible that some bugs are present; but there’s enough of it working that one can happily get an active feel for it right now.
What’s it like to use async/await
? In this article, I’m going to show you. I’ll start by talking about GCD, because that’s what we’re all used to. Then I’ll give a simple GCD use case; I’ll discuss what’s good and what’s bad about this particular way of performing our task. Next, I’ll pause to demonstrate a minor improvement in the code using the Combine framework. Finally, I’ll rewrite the code completely so as to use async/await
. I think you’ll be amazed by the beauty and simplicity of how async/await
tackles the same task.
A little history
When Apple originally introduced Grand Central Dispatch, it was a really exciting development. Here, at last, was an efficient, low-level way of rationalizing multithreaded code in terms of queues and tasks rather than threads. NSOperation and NSOperationQueue already existed (and still do), but GCD was much more convenient to use, and had none of their external overhead.
In practical terms, the appeal of GCD rests on two features:
-
When you make a DispatchQueue, it’s a serial queue. This means that no task in the queue can get started until all previously enqueued tasks have finished.
-
GCD is expressed mostly using Objective-C blocks and Swift anonymous functions, often referred to as closures. Thanks to this block-based mode of expression, local variables used in one queue are passed directly into the next, provided the second block is nested inside the first.
Together, those two features constitute a form of locking, helping the programmer control the amount of data sharing between threads. Nevertheless, there are some unfortunate downsides.
One problem is what I just said about nesting. When you do something in an async
block on one thread, and you want to do something else in another async
block on another thread after that, you have to nest the second block inside the first. As you switch multiple times between thread contexts — for example, you get yourself from the main thread onto a background thread, and then you need to come back to the main thread, and so on — this structure forces all your code to move deeper and deeper to the right, which can rapidly become ugly and illegible.
An even more insidious problem is that beginners tend not to understand asynchronous code in the first place — and GCD does nothing to help. In fact, it’s downright misleading. Code nested in an asynchronous block is executed later than all the code that surrounds it, including code that comes later on the page. This reversal of the order of time comes as a complete surprise to naive programmers, who naturally tend to expect that code execution will march down the page from top to bottom in an orderly fashion.
This means that you can’t return a value from a function that obtains that value asynchronously — because the function returns before the asynchronous code can even start getting the value. Beginners get very upset about this, and start talking desperate nonsense about “waiting” for the asynchronous code to end before returning from the surrounding function.
The standard solution is to use a completion handler. But completion handlers come with issues of their own. They’re not easy to write. You’re still not actually returning a value; rather, you are calling back into your code at some later, unknown, arbitrary time. And you can’t signal failure by throwing an error, because you can’t throw “into” a completion handler; that’s why so many completion handlers pass both an Optional error and an Optional result (or a Swift Result enum). Plus, completion handlers have a nasty habit of propagating themselves; you can wind up with quite a lot of them, with code inside one completion handler calling yet another completion handler, which can get ugly and confusing.
Incidentally, I’ve posted a series of three online articles trying to cure beginners of their misconceptions about GCD and asynchronous code:
The joy of async/await
syntax, on the other hand, is that it makes those poor misguided beginners turn out to be right after all! With async/await
, you do get to wait for asynchronous code to finish, you can return a value (or throw an error) directly from an asynchronously called function, and your code does march serially down the page in order of execution. This should eliminate a lot of mistakes and misunderstandings, and my rants about how to deal with asynchronous code using GCD can eventually be laid to rest.
Things take time
To illustrate in the simplest possible way, I’m going to repurpose an example that I’ve used for many years: getting a time-consuming calculation off the main thread.
Imagine that we have a view (a UIView subclass), which I’ll call a Mandelbrot view — meaning that it that knows how to draw a simple version of the Mandelbrot set into itself. Constructing the set is a time-consuming operation, involving some deep looping and calculation. Here’s the code. I didn’t write it; I found it online. It probably isn’t a particularly good way to draw the Mandelbrot set; but it is a good way to spend considerable time calculating!
let MANDELBROT_STEPS = 100 // or some other large number
func makeBitmapContext(size:CGSize) -> CGContext { // *
var bitmapBytesPerRow = Int(size.width * 4)
bitmapBytesPerRow += (16 - (bitmapBytesPerRow % 16)) % 16
let colorSpace = CGColorSpaceCreateDeviceRGB()
let prem = CGImageAlphaInfo.premultipliedLast.rawValue
let context = CGContext(data: nil,
width: Int(size.width), height: Int(size.height),
bitsPerComponent: 8, bytesPerRow: bitmapBytesPerRow,
space: colorSpace, bitmapInfo: prem)
return context!
}
func draw(center:CGPoint, bounds:CGRect, zoom:CGFloat, context:CGContext) {
func isInMandelbrotSet(_ re:Float, _ im:Float) -> Bool {
var fl = true
var (x, y, nx, ny) : (Float, Float, Float, Float) = (0,0,0,0)
for _ in 0 ..< MANDELBROT_STEPS {
nx = x*x - y*y + re
ny = 2*x*y + im
if nx*nx + ny*ny > 4 {
fl = false
break
}
x = nx
y = ny
}
return fl
}
context.setAllowsAntialiasing(false)
context.setFillColor(red: 0, green: 0, blue: 0, alpha: 1)
var re : CGFloat
var im : CGFloat
let maxi = Int(bounds.size.width)
let maxj = Int(bounds.size.height)
for i in 0 ..< maxi {
for j in 0 ..< maxj {
re = (CGFloat(i) - 1.33 * center.x) / 160
im = (CGFloat(j) - 1.0 * center.y) / 160
re /= zoom
im /= zoom
if (isInMandelbrotSet(Float(re), Float(im))) {
context.fill (CGRect(x:CGFloat(i), y:CGFloat(j), width:1.0, height:1.0))
}
}
}
}
How does the Mandelbrot view use that code? Well, to generate a bitmap context containing a drawing of the Mandelbrot set, we call those two methods in succession:
let bounds = self.bounds
let center = CGPoint(x: bounds.midX, y: bounds.midY)
let bitmap = self.makeBitmapContext(size: bounds.size)
self.draw(center: center, bounds: bounds, zoom: 1, context: bitmap)
The idea is that we can then call self.setNeedsDisplay()
to make the view’s own native draw(_:)
method get called. Suppose we’ve got a bitmapContext
property of our own:
var bitmapContext: CGContext!
Then we can say:
self.bitmapContext = bitmap
self.setNeedsDisplay()
Our draw(_:)
method will then be called, and we can copy the bitmap context as an image into our own graphics context:
override func draw(_ rect: CGRect) {
if self.bitmapContext != nil {
if let im = self.bitmapContext.makeImage() {
context.draw(im, in: self.bounds)
}
}
}
Why multithreading
So far, so good. But the core code here, the call to self.draw(center:bounds:zoom:context:)
, is time-consuming. And performing time-consuming code on the main thread is bad. It locks up the user interface; the user cannot interact with the app, and nothing appears to be happening. If this goes on for long enough, in fact, the iOS WatchDog process will kill our app dead before the user’s very eyes.
So we’d like to perform the time-consuming calculation code on a background thread. However, to refer to self.bounds
, we need to be on the main thread. And to set self.bitmapContext
and call self.setNeedsDisplay()
, we need to be on the main thread too. So we’ve got a succession of context switches:
// on the main thread:
let bounds = self.bounds
let center = CGPoint(x: bounds.midX, y: bounds.midY)
// on a background thread:
let bitmap = self.makeBitmapContext(size: bounds.size)
self.draw(center: center, bounds: bounds, zoom: 1, context: bitmap)
// on the main thread:
self.bitmapContext = bitmap
self.setNeedsDisplay()
How are we going to do that?
Using GCD
With GCD, switching threads as we move down through the code depends, as I said before, on nested closures. First, you declare a background serial queue, typically as an instance property:
let draw_queue = DispatchQueue(label: "com.neuburg.mandeldraw")
Then, when it’s time to draw the Mandelbrot set, you just keep calling async
on some dispatch queue — either that background serial queue, or the main queue — every time you want to switch threads. Each call to async
takes a function that is typically expressed directly as a closure. And those functions are nested:
let bounds = self.bounds
let center = CGPoint(x: bounds.midX, y: bounds.midY)
self.draw_queue.async {
let bitmap = self.makeBitmapContext(size: bounds.size)
self.draw(center: center, bounds: bounds, zoom: 1, context: bitmap)
DispatchQueue.main.async {
self.bitmapContext = bitmap
self.setNeedsDisplay()
}
}
Now, please don’t get me wrong. That code is simple and direct, and I happen to think that in its way it is absolutely delightful. I remember how excited I was about that sort of thing when GCD was first announced. It was so much better than what we had to do previously, using NSOperation or (horror of horrors) manipulating threads directly. Here are some reasons why:
-
We don’t have any code sharing across threads, because the variables are all local. Swift functions are closures, and a variable declared before the start of an anonymous function is captured by that function. So
center
andbounds
just magically “fall through” into the background queue code, and thenbitmap
just magically “falls through” into the final main thread code. -
The call to the time-consuming
self.draw(center:bounds:zoom:context:)
is on a background thread, and moreover, it’s on a serial queue. So, no matter how many times this code may run in quick succession, we will automatically “wait” until one call has finished before starting on another one; a serial queue is a form of data locking. -
It is crucial that calls to UIKit interface properties and methods, such as
self.bounds
andself.setNeedsDisplay
, be made on the main thread. Indeed, nowadays the Main Thread Checker will ding us if we don’t adhere to that rule. Well, we are able to confine those calls to the blocks that are on the main thread, and therefore all will be well. -
Our own property,
self.bitmapContext
, is in danger of incoherence if it is touched from multiple threads (also known as a data race). We are able to be careful about that, too, touchingself.bitmapContext
only on the main thread and eliminating the danger.
All the same, this kind of thing is hard to write and hard to read. The nesting, in particular, threatens to get out of hand; if you have several context switches in a row, the nesting can get really deep. I’ve experienced that, and it isn’t pretty. Plus, you have to give some careful thought as to how the local variables are passed down into the deeper levels of nesting. And you still have to be extra vigilant about data races and the main thread. In other words, yes, we wrote some nice code, but it was sort of exhausting, and one still has a sense that maybe unknown problems remain.
Finally, beginners, in particular, don’t always grasp that for this to work, each async
call needs to be the last thing in its block, because any code after the async
block will run before the async
block — that’s what “asynchronous” means.
Interlude: a Combine rewrite
Before I get to how we might do all this with async/await
, I’d like to tell you about an intermediate solution using the Combine framework. I was really intrigued when Combine was introduced in iOS 13, and I spent a lot of time adapting my GCD code to use it. (If you don’t know about the Combine framework, you might like to look at my online book about it.)
The Combine framework expresses stages of a procedure as steps in a pipeline, known as operators. With Combine, the way to switch threads (queues) is the receive(on:)
operator. And the way to do some arbitrary thing with the arriving input value and pass some arbitrary output value on to the next operator is with the map
operator. So instead of GCD async
, we can use Combine receive(on:)
and map
, like this:
.receive(on: DispatchQueue.main)
.map { () -> (CGPoint, CGRect) in
let bounds = self.bounds
let center = CGPoint(x: bounds.midX, y: bounds.midY)
return (center:center, bounds:bounds)
}
.receive(on: draw_queue)
.map { (center, bounds) -> CGContext in
let bitmap = self.makeBitmapContext(size: bounds.size)
self.draw(center: center, bounds: bounds, zoom: 1, context: bitmap)
return bitmap
}
.receive(on: DispatchQueue.main)
.map { (bitmap:CGContext) -> () in
self.bitmapContext = bitmap
self.setNeedsDisplay()
}
Now, I’ve left out a bunch of details here. That code is just a sequence of Combine operators; to be useful, that sequence has to form part of an overall pipeline with a publisher at the start and a subscriber at the end — and I’ve omitted that part of the code. But forget about that for a moment and just look at the sequence. Notice anything? It’s flat! This code is really easy to read and really easy to write. It just goes step by step down the page, and it says exactly what it means:
- Make sure you’re on the main queue.
- Get the
center
andbounds
and pass them on down the pipeline. - Get on the
draw_queue
queue. - Create and populate the bitmap context and pass it on down the pipeline.
- Get back on the main queue.
- Set our
bitmap
property and ask for a redraw.
I admit that my Combine code is more verbose than GCD — especially the way I write Combine code, since I really like to be very explicit about the incoming and outgoing types in a map function. But I actually like that verbosity, exactly because it’s explicit. Nothing just magically “falls through” to the next step; we have to pass values down the pipeline. Each map
call says clearly what it is passing, and the next map
calls says clearly what it is receiving. Pretty neat!
Nonetheless, even after converting my code to Combine, I’m still using GCD. The parameters in my receive(on:)
calls are GCD dispatch queues. And although my Combine code is delightfully flat, nevertheless it’s arguably top-heavy. But it was the best I could do — until async/await
arrived on the scene.
Watch your language
The idea of the async/await
language construct is that you get to do exactly what I was saying you can’t do with GCD: you can wait for asynchronous code to finish.
In brief, the rule is this: If a function is marked async
, then when you call it, you have to say await
— and this is a signal that your code will pause on that line while the asynchronous call is performed. Later, when the asynchronous call finishes, your code resumes at the next line after the await
, marching down the page like ordinary code.
I first heard about async/await
through JavaScript. Naturally, like many other people, I immediately thought to myself: Swift needs this! Fortunately, the Swift people themselves were thinking the same thing, only on an even grander scale. Chris Lattner, who invented Swift to begin with, has been saying for years that Swift needs built-in language-level support for grappling with multithreaded code; as early as 2017 he had posted an extensive concurrency manifesto laying out the direction that this might take.
Actual proposals soon began to make their way through the Swift language evolution process, and so we arrive now at the Swift 5.5 beta, released as part of WWDC 2021, which showcases a whole suite of new native Swift language features related to multithreading.
My Mandelbrot example is actually far too simple-minded to illustrate the full power of Swift 5.5’s innovations. But for that very reason, I think it’s a good starting place for pedagogical purposes. Let’s convert my original GCD code to use async/await
!
An actor prepares
I’m going to start in what may seem rather an odd place — with an actor. An actor, in Swift 5.5, is an object type, on a par with enums, structs, and classes. Its primary purpose, according to the WWDC 2021 videos, is to protect shared state. But the way I look at it, an actor does for modern Swift concurrency what queues did for GCD.
All actors are divided into two types: the main actor, and all others. The main actor isn’t something you make; it is an invisible global actor. A type (or method) that is marked as belonging to the main actor wants to run its code on the main thread. UIKit types such as UIViewController and UIView come already marked as belonging to the main actor, thus helping to ensure that their code will run on the main thread. All other actors, actors that you create, want to run their code on a background thread.
So this is how we’re going to express the idea that our calculations should be performed on a background thread: we’re going to create an actor
type embodying those calculations, like this:
class MyMandelbrotView : UIView {
var bitmapContext: CGContext!
actor MyMandelbrotCalculator {
private let MANDELBROT_STEPS = 100 // or whatever
func drawThatPuppy(center:CGPoint, bounds:CGRect) -> CGContext {
let bitmap = self.makeBitmapContext(size: bounds.size)
self.draw(center: center, bounds: bounds, zoom: 1, context: bitmap)
return bitmap
}
private func makeBitmapContext(size:CGSize) -> CGContext {
// ... create context ...
return context!
}
private func draw(center:CGPoint, bounds:CGRect, zoom:CGFloat, context:CGContext) {
// ... operate on context ...
}
}
let calc = MyMandelbrotCalculator()
// ...
}
The beautiful thing about this architecture is that we now have an object with just one public entry point: drawThatPuppy
. It takes the initial center
and bounds
values, and returns the fully drawn CGContext portraying the Mandelbrot set. Of course, we could have done all that with an ordinary struct. But remember, with an actor, we are also implicitly saying that our code should run on a background thread.
And here’s the really amazing part: there’s an additional rule, which will be enforced for us automatically, that only one outside call to an actor method can be running at any one time. In other words, the actor behaves much like a GCD serial dispatch queue!
Thanks to the actor, our object architecture is aligned with our threading architecture. Instead of having some methods that run on the main thread and others that run on a background thread, we have objects that operate on the main thread and objects that operate on a background thread.
Asyncing feeling
So I’ve now given my view a property, calc
, which is an instance of this actor. To ask for the calculation to be performed, I’ll call into the actor’s public method. I’ll do that from a different drawThatPuppy
method, belonging to the view:
func drawThatPuppy() {
let bounds = self.bounds
let center = CGPoint(x: bounds.midX, y: bounds.midY)
self.bitmapContext =
self.calc.drawThatPuppy(center: center, bounds: bounds)
// ...
}
We reach this point, and we are forced to stop; there’s a compiler error. But that’s not bad; it’s good! The compiler is going to help us with the rest of our code. The error message at this point is a little mysterious: it says, “Actor-isolated instance method drawThatPuppy(center:bounds:)
can only be referenced from inside the actor.” What that really means is: Where’s the asynchronous part of all this?
Well, where is the asynchronous part? If this drawThatPuppy
method is just an ordinary method of the UIView, it’s going to be called on the main thread. So now we are trying to call a method of the actor on the main thread. But this actor, you remember, wants to run on a background thread. We need to give the compiler permission to step off the main thread and call the actor on a background thread instead.
One way to do that, in Swift 5.5, is to mark the surrounding method as async
, like this:
func drawThatPuppy() async { // *
let bounds = self.bounds
let center = CGPoint(x: bounds.midX, y: bounds.midY)
self.bitmapContext =
self.calc.drawThatPuppy(center: center, bounds: bounds)
// ...
}
Fine, but now the compiler has a new complaint: “Expression is async
but is not marked with await
.” And it offers a Fix-It. We accept the Fix-It, and this is what we get:
func drawThatPuppy() async {
let bounds = self.bounds
let center = CGPoint(x: bounds.midX, y: bounds.midY)
self.bitmapContext =
await self.calc.drawThatPuppy(center: center, bounds: bounds) // *
// ...
}
All right, fine, our code now compiles. Let’s finish writing this method; we just need one more line, the call to self.setNeedsDisplay
:
func drawThatPuppy() async {
let bounds = self.bounds
let center = CGPoint(x: bounds.midX, y: bounds.midY)
self.bitmapContext =
await self.calc.drawThatPuppy(center: center, bounds: bounds)
self.setNeedsDisplay()
}
Excellent. Our code still compiles. But now we can’t run the code. Our code has no place to stand.
Taking a stand
To see what I mean, let’s say there’s a button in the interface that calls an action method that is supposed to call drawThatPuppy
:
@IBAction func doButton (_ sender: Any) {
self.mv.drawThatPuppy() // self.mv is the Mandelbrot UIView
}
We get a new compiler error: “async
call in a function that does not support concurrency.” And the compiler offers us a Fix-It: we can mark this method as async
as well:
@IBAction func doButton (_ sender: Any) async { // NO!
But we mustn’t do that! This is a method that Objective-C is going to call for us when the button is tapped; Objective-C here has no idea how to call this action method as an async
Swift method. What are we going to do now?
This is a problem that first-time users of Swift 5.5 encounter right away. You convert a method to be async
, but then you have to convert the caller of that method to be async
, and then you have to convert the caller of that method to be async
, and so on. The chain never seems to stop. But somewhere, it must stop; we need a place to take a stand within the good old world of normal methods, a place where we can say: This is not an async
method, even though it needs to call an async
method.
The main way you do that, in Swift 5.5, is by declaring a Task object, like this:
@IBAction func doButton (_ sender: Any) {
Task { // *
self.mv.drawThatPuppy()
}
}
Now we get our await
compiler error once again: “Expression is async
but is not marked with await
.” We accept the Fix-It again, and we end up with this:
@IBAction func doButton (_ sender: Any) {
Task {
await self.mv.drawThatPuppy() // *
}
}
Our code now compiles! Not only that, it runs. Not only that, it runs correctly.
What we’re actually doing with the term Task
is calling the initializer of the Task struct. This initializer takes a function as its parameter, and the function (supplied using trailing closure syntax) is where we call our asynchronous drawThatPuppy
. We don’t need to retain the resulting Task object! We don’t need that object for anything. Merely initializing the Task causes our function to be called asynchronously.
Pull on a thread
What do I mean by saying that the code runs correctly? What does this code actually even do?
Let’s take stock. We start with a normal doButton
action method. It creates a Task, which calls an async
method, our Mandelbrot view’s drawThatPuppy
, using the word await
. Our Mandelbrot view’s drawThatPuppy
, which is an async
method, calls an actor method self.calc.drawThatPuppy
, using the word await
. And that’s all.
At this point you should be rounding upon me in fury and saying: “What do you mean, that’s all? Where’s the threading? Where’s the context switching? Where are the queues? We set out to run some code in the background, remember? What happened to that?”
But keep your shirt on! I assure you, we’ve taken care of all that. Return once again to the Mandelbrot view’s drawThatPuppy
:
func drawThatPuppy() async {
let bounds = self.bounds
let center = CGPoint(x: bounds.midX, y: bounds.midY)
self.bitmapContext =
await self.calc.drawThatPuppy(center: center, bounds: bounds)
self.setNeedsDisplay()
}
All of that code runs on the main thread, because a UIView is marked as belonging to the main actor. So all the right things happen. We fetch our bounds
on the main thread. We set our bitmapContext
property on the main thread. We call setNeedsDisplay
on the main thread. All very right and proper and necessary.
But the call to self.calc.drawThatPuppy
runs on a background thread, because self.calc
is a background actor! Do you see the point? We don’t have to talk about threads at all! Because of our object architecture, the right thing happens, all by itself.
Okay, but wait a moment. How can that be? self.calc.drawThatPuppy
runs on a background thread and takes a lot of time. Meanwhile, why doesn’t our code just proceed merrily on its way on the main thread? Oh, I can answer that one. It’s because of the word await
! This word means just what it says: Stop and wait for this call to complete, no matter how long it takes.
But now, I’m guessing, you probably want to say to me: “Stop and wait? When you’re on the main thread?? Are you crazy? You’re blocking the main thread! Everyone knows that’s a first-class crime!” No. Get ready for this: Waiting does not block. That’s not what await
does.
When we reach the word await
, the code is suspended, but the thread is not. The main thread just walks off and leaves that code in a frozen state, and continues to run. The user can continue to interact with the interface, other code can run on the main thread, and so forth. When the background work finishes, the background thread, in its turn, submits its result and goes on to other things. Perhaps at that moment the main thread is busy doing something else; no problem. Sooner or later, when the main thread is free, it receives back the result, and our code continues on its way once more from where it left off.
And that, of course, is the other amazing thing about this code. The call to self.calc.drawThatPuppy
returns a value — across the context barrier between one thread and another. That is exactly the kind of thing that GCD can’t do — because it can’t wait.
We thus end up with the most natural-looking code imaginable. The code just steps through its lines one at a time in order. The third line of drawThatPuppy
is really not all that special. We call a method that returns a value, and we get back that value, just like normal code. And yet, in that one line, the path of execution crosses from the main thread to a background thread, performs time-consuming work on the background thread, and comes back again to the main thread.
Moreover, this code is safe and orderly. Recall how we hooked everything up to a button in the interface. The user can tap that button several times in a row. The first time, our actor isn’t doing anything, and the calculation code starts to run immediately (on a background thread). But each time after that, the actor is busy! This means that the await
lines all just have to wait until the actor is finished so that it can begin processing another calculation. But none of that affects the main thread or the interface; the user can keep working, and eventually the view will update with each new drawing as it finishes. That’s what I mean when I say that our actor is like a serial queue.
What we learned
In this very simple example, we have just dipped our toes into the world of async/await
. Yet we have experienced, in action, the key basis of the new Swift concurrency regime. What did we find out?
-
The main actor is a global object that wants its code to be run on the main thread. UIKit objects like UIViewController and UIView belong to the main actor, so they want their code to be run on the main thread.
-
An actor that you create is an object that wants its code to be run on a background thread.
-
An actor that you create also has the special power that its code cannot be called and running from the outside multiple times simultaneously, so there is no danger of simultaneous access.
-
Because of the actor’s special power, code that calls into an actor from outside might have to wait (because that code might be running already). Therefore, such a call must be marked with
await
. -
A call marked with
await
will pause at the point of the call, waiting for the call to be completed. This means that the call can return a value to the waiting caller. -
Code that says
await
must itself be asynchronous, so that it is capable of waiting. There are two primary ways in which code can be made asynchronous:-
An entire method can be declared
async
. Such a method can only be called withawait
. -
A block of code can be called asynchronously by creating a Task object and handing that code to its initializer. That can be done in a method that is not itself asynchronous.
-
The example I used to demonstrate all this was deliberately very simple-minded. There are only two concurrent things happening: the main thread, and the background calculation of the Mandelbrot set. In my implementation, only one such background calculation can happen at a time; if the user asks for two calculations in quick succession, the second calculation cannot even start until the first has completed. That’s perhaps not a very efficient use of the device’s resources! But it does demonstrate how easily and simply such a thing can be configured.
A new world
Now that you know about the async/await
syntax, you’re ready to begin using it immediately. You don’t even have to write any new code; you can adapt your old code. Apple has already supplied new versions of some of its most popular asynchronous methods; those new versions use async/await
instead of a completion handler. And you can start calling them!
Take, for example, networking. You are probably used to calling this URLSession method:
func dataTask(with url: URL,
completionHandler: @escaping (Data?, URLResponse?, Error?) -> Void)
-> URLSessionDataTask
You obtain the data task, call resume
on it, and receive the response in a completion handler that you supply. The completion handler takes three parameters: the data received from the network, the response, and also an Error object in case there is an error (because completion handlers can’t throw). Any of those might be missing — if there’s an error, there’s no data, but if there’s data, there’s no error — so they are all Optional and you have to test and unwrap them. You know the drill.
Instead, you can now call this method:
func data(from url: URL, delegate: URLSessionTaskDelegate? = nil) async throws
-> (Data, URLResponse)
That’s an async
method. You call it with await
— actually, you would have to say try await
because it can throw to signal an error — and it returns a tuple of the data and the response directly to you, the caller. There’s no resume
. There’s no completion handler. And there’s no Error value returned to you, because if there’s an error, the called method can throw!
Here’s a basic example of calling the new async
method:
@MainActor func doSomeNetworking() async {
guard let url = URL(string:"https://www.apeth.com/pep/manny.jpg") else {return}
do {
let (data, resp) = try await URLSession.shared.data(from: url)
guard (resp as? HTTPURLResponse)?.statusCode == 200 else {
print("bad response")
return
}
if let image = UIImage(data: data) {
self.imageView?.image = image
}
} catch {
print(error)
}
}
Here are some things to notice about that code.
We have to say await
as we call URLSession.shared.data(from: url)
, which means we need to be in an async
context; in my implementation, the whole surrounding method (doSomeNetworking
) is marked async
. But the method we are calling is also a throws
method, so we have to obey the rules about that, too; in addition to await
, we also say try
, and we can only do that in an appropriate context — either in a do...catch
construct, or in a throws
method. Here, I’ve opted for the former.
There is no callback into our completion handler; we have no completion handler! Instead, after the await
call to URLSession.shared.data(from: url)
, the code, which was paused, just resumes and carries straight on. If the call doesn’t throw, the results have been simply returned directly to us as a tuple. I check both results in turn. If the response is good and the data turns out to be image data, as expected, I assign it to an image view property as its image. But that requires that I be on the main thread; to ensure that, I’ve marked this method as @MainActor
. Pretty easy!
Asynchronous sequences
Another intriguing update to the existing APIs is that some things are now available as asynchronous sequences. An asynchronous sequence is rather like the stream of values that arrive from a Combine framework publisher: the values arrive whenever they happen to arrive. To receive them, you can loop over the sequence asynchronously.
A case in point is notifications from the NotificationCenter. In this test code, we arrange to receive notifications called "testing"
sent by self
:
let notif = NotificationCenter.default
let notifs = notif.notifications(
named: Notification.Name(rawValue: "testing"),
object: self)
for await n in notifs {
// n is a notification! do something with it
}
The surprise here is the expression for await
. It says that each n
in the sequence can arrive at any old time, so we will just pause looping until the next one actually does arrive. We are saying await
, so this is code which, once again, can only live in an async
context.
Similarly, a URLSession now offers a bytes
method, which returns an asynchronous sequence that itself has a lines
method that also returns an asynchronous sequence. Looping over this with for await
, you can receive and process downloaded data one line at a time.
What we didn’t learn yet
You now know more than enough to get started with async/await
! As I’ve suggested, a good approach is to find some asynchronous calls in your existing code, and switch over to an async/await
alternative.
So what didn’t we learn yet? Lots! Here are some of the topics that call for further exploration:
-
What if we need to cancel an asynchronous task?
-
How do we dictate the priority with which an asynchronous task should be executed?
-
How do we spawn multiple simultaneous asynchronous tasks?
-
What if we have to call into code that uses a completion handler? How can we incorporate that code into the
await/async
architecture? -
How do we write unit tests for the new style of asynchronous code?
My plan is to introduce those topics in a later article.
One word of caution. As far as I can tell, async/await
isn’t just language-bound but iOS version-bound. If you have code that needs to run on a system earlier than iOS 15, which appears in beta in Xcode 13, you won’t be able to retire your GCD and completion handler–based code. Nevertheless, using Swift’s availability checking, you can experiment with the async/await
alternatives.
Also, as I’ve implied already, these features are not yet fully baked. There may be bugs (I believe I’ve found some), and things may change quite a bit before it all goes final. So keep an open mind, stay flexible, and enjoy the splendid new world of Swift async/await
.