Async/Await in Swift 5.5

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 to Album.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 ❤️

Further Reading

Für neue Blogupdates anmelden:


Schreibe einen Kommentar

Deine E-Mail-Adresse wird nicht veröffentlicht. Erforderliche Felder sind mit * markiert.