Async Basics In Swift
At WWDC21 we finally got access to something we've been after for as long as I've been making apps - async/await in Swift. This will allow us to clean up lots of nested closure-based code and bring things closer in-line to other platforms. If you've used a modern Kotlin app, you'll no doubt find it a little jarring coming back to Swift from the world of co-routines. The new concurrency additions should make that transition very smooth.
What is it?
You've probably seen async/await allover Twitter, but might not have a clue what it is. If you've only ever done Swift, you could easily have never worked in a codebase that supports similar concurrency.
Swifts new concurrency support is a way to write "asynchronous and parallel code in a structured way.". To put that simply, it provides structure to the idea of "do thing one and then two, but whilst you're waiting for two go do other stuff". It allows your code to wait for somethinng to finish before returning to the current scope. Think pausing whilst waiting for a network request to come back, updating bits of UI, then updating again when the network is finished. Its really powerful.
How do I use it?
Don't be scared by all this - Swift has made it incredibly easy to get started.
Lets take an example of some traditional code with a closure, and compare it to what that same code would look like when we take advantage of the concurrency features in Swift.
class AlbumGenerator {
func getAlbumNames(albumsClosure: (([String]) -> ())) {
albumsClosure(["Folklore", "Evermore", "Fearless (Taylor's Version)", "Red (Taylor's Version)"])
}
}
let albumGenerator = AlbumGenerator()
albumGenerator.getAlbumNames { albums in
print(albums)
}
class AlbumGenerator {
func getAlbumNames(albumsClosure: (([String]) -> ())) {
albumsClosure(["Folklore", "Evermore", "Fearless (Taylor's Version)", "Red (Taylor's Version)"])
}
}
let albumGenerator = AlbumGenerator()
albumGenerator.getAlbumNames { albums in
print(albums)
}
Here, we have a simple class that generates a list of album names. Calling that is as simple as you'd think, just call .getAlbumNames
and attatch a trailing closure. So what would that look like if we take advantage of concurrency support?
class AsyncAlbumGenerator {
func getAlbumNames() async -> [String] {
Thread.sleep(forTimeInterval: 2) // Fake a delay by sleeping for 2 seconds
return ["Folklore", "Evermore", "Fearless (Taylor's Version)", "Red (Taylor's Version)"]
}
}
async {
let albumGenerator = AsyncAlbumGenerator()
let albums = await albumGenerator.getAlbumNames()
print(albums)
}
print("Fetching Taylor Swift's Albums")
class AsyncAlbumGenerator {
func getAlbumNames() async -> [String] {
Thread.sleep(forTimeInterval: 2) // Fake a delay by sleeping for 2 seconds
return ["Folklore", "Evermore", "Fearless (Taylor's Version)", "Red (Taylor's Version)"]
}
}
async {
let albumGenerator = AsyncAlbumGenerator()
let albums = await albumGenerator.getAlbumNames()
print(albums)
}
print("Fetching Taylor Swift's Albums")
I've created a new AsyncAlbumGenerator
class, and this time the getAlbumNames
function returns an array of strings, instead of passing those strings to an injected closure. At the call site, this means instead of adding the trailing closure, I can just await
the response and continue with what I was doing.
The output from our above code would be this:
Fetching Taylor Swifts Albums
["Folklore", "Evermore", "Fearless (Taylor\'s Version)", "Red (Taylor\'s Version)"]
Fetching Taylor Swifts Albums
["Folklore", "Evermore", "Fearless (Taylor\'s Version)", "Red (Taylor\'s Version)"]
Let's break this code down into seperate sections to understand exactly whats going on.
First, the declaration of the function is a little different to what we're used to - it has a new keyword.
func getAlbumNames() async -> [String]
func getAlbumNames() async -> [String]
Instead of our usual -> [String]
we've got async -> [String]
. The async
keyword tells Swift that this function can only be called from another async context, and lets us use other async code in this same function.
Next, the place where we call this function looks different too - its wrapped in the new async
block.
async {
// Some async stuff
}
async {
// Some async stuff
}
Similar to the async keyword above, this informs Swift that we're going to do something async. Anything inside the block is within an async context, so we can call our async functions here. This code block is an example of unstructured concurrency, where under the hood Swift will make a new task for it that isn't attatched to any other, and we don't keep hold of that task ourselves.
When the task is created, Swift will go off and do its thing, letting everything else continue. That's why our print statement outside of the block gets called first, and then the print statement inside only gets called once the work is finished. You might want to use this async { }
approach to kick off some work when your View Controller loads.
How do I stop it?
Sometimes, you're going to want to cancel a Task. Think about if you have a network request in progress and the user leaves that screen - there's no point returning the data you just fetched. With structured concurrency, this is very simple. You can store the Task that Swift generates and cancel it, like so:
let albumTask = async {
let albumGenerator = AsyncAlbumGenerator()
let albums = try await albumGenerator.getAlbumNames()
print(albums)
}
print("Fetching Taylor Swifts Albums")
albumTask.cancel()
let albumTask = async {
let albumGenerator = AsyncAlbumGenerator()
let albums = try await albumGenerator.getAlbumNames()
print(albums)
}
print("Fetching Taylor Swifts Albums")
albumTask.cancel()
The output here hasn't changed - its exactly what we had earlier. This is because Swift wont just stop all the work inside the task for us.
Fetching Taylor Swifts Albums
["Folklore", "Evermore", "Fearless (Taylor\'s Version)", "Red (Taylor\'s Version)"]
Whilst Swift will now tell that task "you're cancelled" it won't stop the work thats going on. Instead, its on you the developer to check if you're cancelled or not. We can do this using the Task.checkCancellation
method. Lets take a look at that in action.
class CancellationAwareAlbumGenerator {
func getAlbumNames() async throws -> [String] {
Thread.sleep(forTimeInterval: 2)
try Task.checkCancellation()
return ["Taylor Swift", "Fearless", "Speak Now", "Red", "1989", "Reputation", "Lover"]
}
}
let albumTask = async {
let albumGenerator = CancellationAwareAlbumGenerator()
let albums = try await albumGenerator.getAlbumNames()
print(albums)
}
print("Fetching Taylor Swift's Classic Albums")
albumTask.cancel()
class CancellationAwareAlbumGenerator {
func getAlbumNames() async throws -> [String] {
Thread.sleep(forTimeInterval: 2)
try Task.checkCancellation()
return ["Taylor Swift", "Fearless", "Speak Now", "Red", "1989", "Reputation", "Lover"]
}
}
let albumTask = async {
let albumGenerator = CancellationAwareAlbumGenerator()
let albums = try await albumGenerator.getAlbumNames()
print(albums)
}
print("Fetching Taylor Swift's Classic Albums")
albumTask.cancel()
After the wait - which would typically be a network call instead - I check if the task is cancelled, which will throw an error. This error is a CancellationError
. You'll notice that I also marked the function as async throws
instead of just async
, which allows me to throw errrors from inside the function and stop execution. In this instance, as the task gets cancelled before it gets a chance to return the album names, it will print nothing,
The output here ends with our first print - the task never got to finish.
Fetching Taylor Swift's Classic Albums
Fetching Taylor Swift's Classic Albums
In the above example I took advantage of the erorr throwing to pause my execution in one-line, but it added some complexity as I I'll have to make sure wherever calls this code can handle the error. There's an alternative solution we can look to if we don't want to handle the error, using Task.isCancelled
.
class CancellationCheckingAlbumGenerator {
func getAlbumNames() async -> [String] {
Thread.sleep(forTimeInterval: 2)
if Task.isCancelled {
return []
} else {
return ["Taylor Swift", "Fearless", "Speak Now", "Red", "1989", "Reputation", "Lover"]
}
}
}
let secondAlbumTask = async {
let albumGenerator = CancellationCheckingAlbumGenerator()
let albums = await albumGenerator.getAlbumNames()
print(albums)
}
print("Fetching Taylor Swift's Classic Albums")
secondAlbumTask.cancel()
class CancellationCheckingAlbumGenerator {
func getAlbumNames() async -> [String] {
Thread.sleep(forTimeInterval: 2)
if Task.isCancelled {
return []
} else {
return ["Taylor Swift", "Fearless", "Speak Now", "Red", "1989", "Reputation", "Lover"]
}
}
}
let secondAlbumTask = async {
let albumGenerator = CancellationCheckingAlbumGenerator()
let albums = await albumGenerator.getAlbumNames()
print(albums)
}
print("Fetching Taylor Swift's Classic Albums")
secondAlbumTask.cancel()
In this example, I havent had to mark my function as throwing, nor have I had to mark the function call with try when It came to using it. The downside here is that I now can't exit when I realise the task is cancelled, I have to return something, so I just return something empty.
Our output here would be the below - our initial print fires, we wait two seconds, the Task realises its cancelled and just returns an empty array.
Fetching Taylor Swift's Classic Albums
[]
Personally, I'd reccomend using the throwing approach, as I believe it leads to cleaner code when you apply this pattern throughout - but thats just me.
Conclusion
So thats the basics of Async/Await in swift - I hope it helped you understand it a little better. You can find the code samples in my repo as always.
If you fancy reading a little more, I’m @SwiftyAlex on twitter.
Useful links:
The Swift guide to concurrency: https://docs.swift.org/swift-book/LanguageGuide/Concurrency.html