0
cart
Aug 12, 2024
10 min read

Async await in Swift explained with code examples

Swift
Concurrency

Async await is part of the new structured concurrency changes that arrived in Swift 5.5 during WWDC 2021. Concurrency in Swift means allowing multiple pieces of code to run at the same time. This is a very simplified description, but it should give you an idea already how important concurrency in Swift is for the performance of your apps. With the new async methods and await statements, we can define methods performing work asynchronously.

You might have read about the Swift Concurrency Manifesto by Chris Lattner before, which was announced a few years back. It described the future ahead, which has been made concrete with the release of Swift 6 in 2024. Now that it’s finally here, we can simplify our code with async-await and make our asynchronous code easier to read.

What is async?

Async stands for asynchronous and can be seen as a method attribute, making it clear that a method performs asynchronous work. An example of such a method looks as follows:

func fetchImages() async throws -> [UIImage] {
    // .. perform data request
}

The fetchImages method is defined as async throwing, which means that it performs a failable asynchronous job. If everything goes well, the method returns a collection of images or throws an error.

How async replaces closure completion callbacks

Async methods replace the often-seen closure completion callbacks. Completion callbacks were common in Swift to return from an asynchronous task, often combined with a Result type parameter. The above method would have been written as follows:

func fetchImages(completion: (Result<{UIImage], Error>) -> Void) {
    // .. perform data request
}

Defining a method using a completion closure is still possible in Swift today, but it has a few downsides that are solved by using async instead:

  • You have to call the completion closure in each possible method exit. Not doing so will possibly result in an app endlessly waiting for a result.
  • Closures are more complicated to read. Reasoning about the order of execution is not as easy as it is with structured concurrency.
  • Retain cycles need to be avoided using weak references.
  • Implementors need to switch over the result to get the outcome. Try-catch statements cannot be used at the implementation level.

These downsides are based on the closure version using the Result enum. Some projects might still make use of completion callbacks without this enumeration.

What is await?

Await is the keyword to be used for calling async methods. You can see them as best friends in Swift as one will never go without the other. You could basically say:

“Await is awaiting a callback from his buddy async”

Even though this sounds childish, it’s not a lie! We could take a look at an example by calling our earlier defined async throwing fetch images method:

do {
    let images = try await fetchImages()
    print("Fetched \(images.count) images.")
} catch {
    print("Fetching images failed with error \(error)")
}

It might be hard to believe, but the above code example performs an asynchronous task. Using the await keyword, we tell our program to await a result from the fetchImages method and only continue after a result arrives. This could be a collection of images or an error if anything went wrong while fetching the images.

What is structured concurrency?

Structured concurrency with async-await method calls makes it easier to reason about the execution order. Methods are linearly executed without going back and forth as you would with closures.

To explain this better, we can look at how we would call the above code example before structured concurrency arrived:

// 1. Call the method
fetchImages [ result in
    // 3. The asynchronous method returs
    switch result {
    case .success(let images):
        print("Fetched \(images.count) images.")
    case .failure(let error):
        print("Fetching images failed with error \(error)")
    }
 }
 // 2. The calling method exits

As you can see, the calling method returns before the images are fetched. Eventually, a result is received, and we return to our flow within the completion callback. This is an unstructured order of execution and can be hard to follow. This is especially true if we perform another asynchronous method within our completion callback, which would add another closure callback:

// 1. Call the method
fecthImages { result in
    // 3. The asynchronous method returns
    switch result {
    case .success(let images):
        print("Fetched \(images.count) images.")
        
        // 4. Call the resize method
        resizeImages(images) { result in
            // 6. Resize method returns
            switch result {
            case .success(let images):
                print("Decoded \(images.count) images.")
            case .failure(let error):
                print("Decoding images failed with error \(error)")
            }
        }
        // 5. Fetch images method returns
    case .failure(let error):
        print("Fetching images failed with error \(error)")
    }
}
// 2. The calling method exits

The order of execution is linear, so it is easy to follow and reason about. Understanding asynchronous code will be easier when we perform sometimes complex tasks.

How async replaces closure completion callbacks

The Task initializer method creates a new asynchronous context for asynchronous methods. We must use the @MainActor attribute since we’re updating a @Published property that triggers UI updates. You can learn more about this in my article MainActor usage in Swift explained to dispatch to the main thread.

Adopting async-await in an existing project

When adopting async-await in existing projects, you want to be careful not to break all your code simultaneously.

Convert Function to Async

The first refactor option converts the fetch images method into an async variant without keeping the non-async alternative.

Add Async Alternative

When adopting async-await in existing projects, you want to be careful not to break all your code simultaneously.

Conclusion

Async-await in Swift allows for structured concurrency, improving the readability of complex asynchronous code. Completion closures are no longer needed, and calling into multiple asynchronous methods after each other is a lot more readable. Several new types of errors can occur, which will be solvable by ensuring async methods are called from a function supporting concurrency while not mutating any immutable references. With the release of Swift 6 at WWDC ’24 it became even more important to start migrating your projects.

If you like to learn more tips on Swift, check out the Swift category page. Feel free to contact me or tweet to me on Twitter if you have any additional tips or feedback.

Made by Riffmax & Powered by Webflow