With Swift 5.5, the Swift programming language gains a powerful new set of tools for making asynchronous code easier to read, write, and debug. Similar to other languages (e.g., Python, C#), the async/await pattern is added and used plentifully throughout Swift’s standard library.
In this article, I am going to introduce the basics of async/await in Swift 5.5. Also, I will dive in further by looking at how to write asynchronous code in a structured way. Last but not least, we will explore how async/await integrates with SwiftUI and how you can update your existing code to make use of async/await.
Note: Using async/await requires Xcode 13 and is supported by iOS/iPadOS 15, macOS 12 and watchOS 8 (or later).
Status quo
Before Swift 5.5, writing asynchronous code usually required using patterns such as delegates and/or completion handlers. You might have used something similar to the following in your own code:
func download() { fetchData(from: url) { result in switch result { case .success(let data): handleData(data: data) case .failure(let error): print(error.localizedDescription) //TODO: Handle Error } } } func fetchData(from url: URL, _ completionHandler: @escaping (Result<Data, Error>) -> Void) { let dataTask = URLSession.shared.dataTask(with: url) { data, _, error in if let error = error { completionHandler(.failure(error)) } else if let data = data { completionHandler(.success(data)) } } dataTask.resume() }
The fetchData()
method attempts to fetch some data from a given URL and calls the provided completionHandler
with a result type containing either the requested data or any error that might have occurred.
Notice how we call our completionHandler
from inside the dataTask
’s completionHandler closure. These nested callbacks are pretty common in Swift and make it quite difficult to understand the execution order without deep-diving into the code. This makes it an easy target for introducing bugs while at the same time complicating the debugging process.
A better solution
Now let’s attempt to improve our code by using Swift 5.5’s async/await:
func download() async { do { let data = try await fetchData(from: url) handleData(data: data) } catch { print(error.localizedDescription) //TODO: Handle Error } } func fetchData(from url: URL) async throws -> Data { let (data, _) = try await URLSession.shared.data(from: url) return data }
The execution order is much clearer. We reduced the amount of code and improved significantly in terms of readability and comprehensiveness. We also do not need to use the Result type anymore since async/await allows us to handle errors directly using Swift’s error handling.
Basics
Syntax
Any function can be marked as being an asynchronous function by adding the async keyword to its signature:
func download() async { //initiate download //... }
Just as regular functions, async functions can, of course, also return a result if specified:
func downloadData() async -> Data { //initiate download //... return data }
In order to call an async function, we must precede the call by the await keyword:
await download() let data = await downloadData()
Error handling
Async/await fully supports Swift’s built-in error handling. If your async function needs to throw, simply add the throws keyword to its signature:
func download() async throws { ... }
When calling the function, precede it with the try keyword:
do { try await download() } catch { //handle error }
Note how the keyword order is reversed between the function’s signature and its invocation:
async throws
vs. try await
More advanced tools such as TaskGroup or Continuation offer throwing variations as well (more on this later).
Tasks: Bridging between synchronous and asynchronous code
Asynchronous functions in Swift 5.5 can only be called from an asynchronous context.
Now you might ask yourself (rightly so):
How can I use async/await from my synchronous code?
Luckily, Swift 5.5’s standard library introduces the new Task type, enabling us to do just that!
A Task is a unit of work that runs asynchronously from its current context. Tasks can be created both from a synchronous and from an asynchronous context.
func doStuff() { Task { await download() } }
This enables us to use async/await from everywhere inside our code, e.g., in a ViewController’s viewDidLoad()
method:
override func viewDidLoad() { super.viewDidLoad() Task { self.data = await downloadData() } }
Upon creating a Task, we can optionally specify its priority to the operating system:
Task(priority: .background) { ... } Task(priority: .userInitiated) { ... }
If no priority is provided, .medium
is used as the default. Check out the official documentation for all possible values.
Using async/await in a structured way
There’s no doubt that the examples covered thus far already offer a great set of improvements when dealing with asynchronous code in Swift 5.5.
But there’s still more to explore!
In the next chapter, we will be going over some of the more advanced features of async/await, which enable us to parallelize a lot of our work.
MusicLibrary: A practical example
Consider we want to build a simple app to help us organize our music collection.
We come up with the following data model for our app:
struct MusicLibrary { var albums = [Album]() } struct Album { let artist: String let title: String let tracks: [Track] let artwork: UIImage? } struct Track: Decodable { let title: String let duration: Double }
We want to be able to add an album to our library by scanning its barcode. The barcode is then used to asynchronously look up all the relevant information we need to create an instance of our Album type and append it to the library:
extension MusicLibrary { mutating func addAlbum(barcode: String) async { let album = await getAlbumFromBarcode(barcode: barcode) self.albums.append(album) } }
We implement the function getAlbumFromBarcode
as follows:
func getAlbumFromBarcode(barcode: String) async -> Album { let (artist, title, artworkURL, tracklistURL) = await barcodeLookup(code: barcode) let artwork = await getArtwork(url: artworkURL) let tracks = await getTracks(url: tracklistURL) return Album(artist: artist, title: title, tracks: tracks, artwork: artwork) }
First, we use thebarcodeLookup
method to get the album’s details as well as URLs to its artwork and tracklist. We then use these URLs to download both artwork and tracklist and, once done, return the new album.
So far, so good. Right?
While our implementation certainly works as intended, it exposes a limitation of async/await you might run into frequently.
async let
Let’s have a closer look at the following two consecutive statements:
let artwork = await getArtwork(url: artworkURL) let tracks = await getTracks(url: tracklistURL)
The second statement will only get executed once getArtwork
returns a result, meaning the current thread will block for the time being.
Since downloading the tracklist is independent of downloading the artwork, we want to run these two statements in parallel in order to optimize performance.
This is where the new async let syntax comes in handy!
Async let allows us to define an asynchronously provided value without blocking the thread right away.
Let’s update our code to make use of async let:
func getAlbumFromBarcode(barcode: String) async -> Album { let (artist, title, artworkURL, tracklistURL) = await barcodeLookup(code: barcode) async let artwork = getArtwork(url: artworkURL) async let tracks = getTracks(url: tracklistURL) return await Album(artist: artist, title: title, tracks: tracks, artwork: artwork) }
Notice how we have reduced the number of blocking await statements from two to one. When using async let, the await keyword is needed only once you actually access the value(s).
getArtwork
and getTracks
are now executed in parallel, and once both have provided a result, we can return our finished album.
Async let is a great tool if you want to parallelize a static number of tasks. But what if you need a more dynamic solution?
TaskGroup
We are quite happy with our MusicLibrary app so far. But we want to extend it by adding the capability of importing multiple albums at once – a perfect use case for TaskGroup!
TaskGroup offers a dynamic approach for running tasks in parallel. As you might have guessed, a TaskGroup bundles a bunch of Tasks together in order to run them concurrently.
Here’s how we can use TaskGroup for implementing our batch-import feature:
extension MusicLibrary { mutating func addAlbums(barcodes: [String]) async { await withTaskGroup(of: Album.self) { group in for barcode in barcodes { group.addTask { return await getAlbumFromBarcode(barcode: barcode) } } for await album in group { self.albums.append(album) } } } }
Let’s go through it step by step.
- First, we create the TaskGroup using the function
withTaskGroup
. Since each child task is going to return an instance of our Album type, we need to set the ChildTaskResult.Type parameter toAlbum.self
- Inside the closure, we iterate over the provided barcodes array. For each barcode, we add a child task to the group.
- Inside the child task’s body, we use our previously implemented function
getAlbumFromBarcode
to fetch and return the corresponding album. - We can now asynchronously iterate over the group’s results and add each album to our library as they are fetched. (Note: This for await in syntax is called an AsyncSequence. TaskGroup conforms to the AsyncSequence protocol, enabling us to iterate over its results without having to worry about adding data races to our code.)
Note: if you need additional error handling, use withThrowingTaskGroup
instead.
Miscellaneous
Using async/await with SwiftUI
If you’re using SwiftUI, you’ll be happy to know that Apple has added an easy way to attach Tasks to a SwiftUI View using the new .task
view modifier:
ContentView(authorized: $authorized) .task { let authStatus = await MusicAuthorization.request() if authStatus == .authorized { self.authorized = true } else { self.authorized = false } }
In this example, we attach a task to the ContentView
. The task asynchronously checks the user’s authorization status and updates the $authorized
binding so that our subviews can adapt accordingly.
We can also attach a task that runs indefinitely. For this example, we want to listen to incoming notifications using NotificationCenter’s new AsyncSequence API:
ContentView() .task { let nc = NotificationCenter.default for await _ in nc.notifications(named: .MPMusicPlayerControllerNowPlayingItemDidChange) { print("The Now Playing item changed!") } }
You can attach any number of tasks to a given SwiftUI view.
checkedContinuation: Providing async/await wrappers around your existing completionHandler code
Let’s say you are already working with a large codebase, and you don’t want to rewrite all your existing completionHandler code to make use of async/await at this moment. Luckily with checkedContinuations, Swift 5.5 offers a handy tool to easily create async wrappers around your existing code.
Let’s go back to the example shown at the beginning of this article:
func fetchData(from url: URL, _ completionHandler: @escaping (Result<Data, Error>) -> Void) { let dataTask = URLSession.shared.dataTask(with: url) { data, _, error in if let error = error { completionHandler(.failure(error)) } else if let data = data { completionHandler(.success(data)) } } dataTask.resume() }
Using a checkedContinuation, we can easily build an async wrapper around the fetchData
method:
func fetchData(from url: URL) async throws -> Data { return try await withCheckedThrowingContinuation { continuation in fetchData(from: url) { result in switch result { case .success(let data): continuation.resume(returning: data) case .failure(let error): continuation.resume(throwing: error) } } } }
We create the checkedContinuation by calling the function withCheckedContinuation
. (If you do not need error handling, you can use withContinuation
instead).
Inside the continuation, we call our old fetchData
method. From inside its completion handler, we then need to resume the continuation by either returning the fetched data, or by throwing any error that might have occurred.
We can then use our new asynchronously-wrapped function just as you would expect:
let data = try await fetchData(url: someURL)
Note (from Apple’s Documentation): You must call a resume method exactly once on every execution path throughout the program.
Conclusion
In this article, I’ve given you an introduction on how to use Swift 5.5’s new async/await. We’ve also covered some of its advanced features, enabling us to write concurrent code in a more structured way.
Learning and using these new concurrency features these last few months has been a lot of fun and allowed me to optimize and simplify my code, both in new and existing projects. While this article is in no way an exhaustive documentation of everything new, I do hope that it provided you with an overview on how async/await can improve your code.
If you want to learn more about everything new in Swift 5.5, I recommend checking out the links attached at the end.
Thanks for reading ❤️
Schreibe einen Kommentar