Understanding Swift’s async/await with code examples iOS 09.02.2022

Pre Swift 5.5, you used GCD to run asynchronous code via dispatch queues — an abstraction over threads.

GCD’s queue-based model worked well. However, it would cause issues, like:

  • Thread explosion. Creating too many concurrent threads requires constantly switching between active threads. This ultimately slows down your app.
  • Priority inversion. When arbitrary, low-priority tasks block the execution of high-priority tasks waiting in the same queue.
  • Lack of execution hierarchy. Asynchronous code blocks lacked an execution hierarchy, meaning each task was managed independently. This made it difficult to cancel or access running tasks. It also made it complicated for a task to return a result to its caller.
Concurrency means that different pieces of code run at the same time.

The new concurrency model is tightly integrated with the language syntax, the Swift runtime and Xcode. It abstracts away the notion of threads for the developer. Its key new features include:

  1. A cooperative thread pool. The new model transparently manages a pool of threads to ensure it doesn’t exceed the number of CPU cores available. This way, the runtime doesn’t need to create and destroy threads or constantly perform expensive thread switching. Instead, your code can suspend and, later on, resume very quickly on any of the available threads in the pool.
  2. async/await syntax. Swift’s new async/await syntax lets the compiler and the runtime know that a piece of code might suspend and resume execution one or more times in the future. The runtime handles this for you seamlessly, so you don’t have to worry about threads and cores.
  3. Structured concurrency. Each asynchronous task is now part of a hierarchy, with a parent task and a given priority of execution. This hierarchy allows the runtime to cancel all child tasks when a parent is canceled. Furthermore, it allows the runtime to wait for all children to complete before the parent completes.
  4. Context-aware code compilation. The compiler keeps track of whether a given piece of code could run asynchronously. If so, it won’t let you write potentially unsafe code, like mutating shared state. This high level of compiler awareness enables elaborate new features like actors, which differentiate between synchronous and asynchronous access to their state at compile time and protects against inadvertently corrupting data by making it harder to write unsafe code.

Simple example

SwiftUI views offer support for calling asynchronous functions. Also, while a function is concurrently executing, we can change the view without having it blocked.

In this short snippet, we'll integrate an async function that suspends its execution for a few seconds, and at the same time, we can use a button to increase a counter shown in the view.

Create a Service class with the following two functions:

class Service {
    func fetchResult() async -> String {
        await sleep(seconds: 5)
        return "Result"
    }

    private func sleep(seconds: Int) async {
        try? await Task.sleep(nanoseconds: 2000000000) // wait 2 seconds
    }
}

The async keyword in the method’s definition lets the compiler know that the code runs in an asynchronous context. In other words, it says that the code might suspend and resume at will. Also, regardless of how long the method takes to complete, it ultimately returns a value much like a synchronous method does.

In ContentView, add two @State properties, as shown in the following code:

struct ContentView: View {
    @State var value: String = ""
    @State var counter = 0

    let service = Service()

    var body: some View {
        VStack {
            Text(value)
            Text("\(counter)")
            Button {
                counter += 1
            } label: {
                Text("increment")
            }
            .buttonStyle(.bordered)
        }
        .task {
            value = await service.fetchResult()
        }
    }
}

To update a view, SwiftUI requires that the code runs in the main thread. .task{} does this for us by moving the code execution from the background thread to the main thread.

So, the async keyword defines a function as asynchronous. await lets you wait in a non-blocking fashion for the result of the asynchronous function.

Routing code to the main thread

One of possible way to ensure your code is on the main thread is calling MainActor.run().

await MainActor.run {
  ... your UI code ...
}

MainActor is a type that runs code on the main thread. It’s the modern alternative to the well-known DispatchQueue.main, which you might have used in the past.

Fetching remote data in SwiftUI

One of the operations made easier by the async/await model is network operations. Fetching data from a network resource is an asynchronous operation by definition and until now, we had to manage it with the callback mechanism. iOS 15 adds an async/await interface to the URLSession class to embrace the new pattern.

Create the model objects to transform the JSON returned from the network call to Swift struct

struct Post: Codable {
    let userId: Int
    let id: Int
    let title: String
    let body: String
}

Create a Service struct to call the network request and decode the response into a Decodable model:

struct PostService {
    private let decoder: JSONDecoder = {
        let decoder = JSONDecoder()
        decoder.keyDecodingStrategy = .convertFromSnakeCase
        return decoder
    }()

    private func fetch<T: Decodable>(type: T.Type, from urlString: String) async -> T? {
        guard let url = URL(string: urlString) else { return nil }
        do {
            let (data, _) = try await URLSession.shared.data(from: url)
            return try decoder.decode(type, from: data)
        } catch {
            return nil
        }
    }

    func fetchPosts() async -> [Post] {
        await fetch(type: Post.self, 
                    from: "https://jsonplaceholder.typicode.com/posts/") ?? []
    }    
}

Create a ViewModel class to manage the business logic

@MainActor
class PostViewModel: ObservableObject {
    @Published var posts: [Post] = []
    @Published var isLoading = false

    let service = PostService()

    func fetch() async {
        isLoading = true
        result = await service.fetchPosts()
        isLoading = false
    }
}

Since published properties update the UI, any changes that are made to them need to be run on the main thread. By marking a class with the @MainActor attribute, the Swift compiler will guarantee that all methods and properties on it are only ever called from the main actor.

Add a List view in ContentView to present the posts

struct ContentView: View {
    @StateObject var vm = PostViewModel()

    var body: some View {
        List(vm.posts) { post in
            Text(post.title)
        }
        .listStyle(.plain)
        .task {
            await vm.fetch()
        }
    }
}

Fetching remote data in UIKit

The NetworkService looks like this

class NetworkService {
    let session: URLSession

    init(session: URLSession = .shared) {
        self.session = session
    }

    func get<T: Decodable>(url: URL) async throws -> T {
        let urlRequest = URLRequest(url: url)
        let result: (data: Data, response: URLResponse) = try await self.session.data(for: urlRequest)

        let statusCode = (result.response as? HTTPURLResponse)?.statusCode
        guard let code = statusCode, (200..<300) ~= code else {
            throw NetworkError.serverError(statusCode: statusCode)
        }

        return try JSONDecoder().decode(T.self, from: result.data)
    }
}

Let's define a manager that will do a network call

struct UserManager: AsyncAwaitUserService {
    let networkService: NetworkService

    init(networkService: NetworkService = NetworkService()) {
        self.networkService = networkService
    }

    func getUser(id: Int) async throws -> User {
        let url = URL(string: "https://www.server.com/user")!
            .appendingPathComponent("\(id)")
        return try await self.networkService.get(url: url)
    }
}

We can use it with following snippet (somewhere in UIViewController)

let um = UserManager()
var userTask: Task<Void, Never>?

override func viewWillAppear(_ animated: Bool) {
    super.viewWillAppear(animated)
    userTask = Task {   
        do {       
            let user = try await um.getUser(id: 1)       
            updateUI(user)       
        } catch {
            print("Request failed with error: \(error)")
        }  
    }
}

override func viewWillDisappear(_ animated: Bool) {
    super.viewWillDisappear(animated)
    userTask?.cancel()
}

Converting a completion block function to async/await

A continuation is an object that tracks a program’s state at a given point. The Swift concurrency model assigns each asynchronous unit of work a continuation instead of creating an entire thread for it. Concurrency model creates only as many threads as there are available CPU cores, and it switches between continuations instead of between threads, making it more efficient.

With await your current code suspends execution and creates a continuation that represents the entire captured state at the point of suspension. Next step it hands the thread and system resources over to the central handler, which decides what to do next. When the awaited function completes, your original code resumes, as long as no higher priority tasks are pending.

Manually creating continuations allows you to migrate your existing code gradually to the new concurrency model.

There are two continuation API variants:

  1. CheckedContinuation. A mechanism to resume a suspended execution or throw an error. It provides runtime checks for correct usage and logs any misuse.
  2. UnsafeContinuation. An alternative to CheckedContinuation, but without the safety checks. Use this when performance is essential and you don’t need the extra safety.

So, it's clear that async/await is the way that Apple wants us to develop concurrent code in Swift. But what if a framework we have to use still has a completion block-based interface? Swift 5.5 provides a simple way to convert these APIs to async await functions.

Let's take a completion block function for fetching a list of post, with the following signature

func fetchPosts(page: Int, completion: ([Post]) -> Void)

We use the withCheckedContinuation function to transform it into an async function with the following signature:

func fetchPostsAsync(page: Int) async -> [Post] {
    return try await withCheckedContinuation { continuation in
        self.fetchPosts(page: page) { result in
            continuation.resume(with: result)
        }
    }
}

The withCheckedContinuation function suspends the current task, and then it calls the passed callback with a CheckedContinuation object.

Inside the callback, we call the completion block-based function, and when it finishes, we resume the execution of the task via the CheckedContinuation that withCheckedContinuation provided.

An important note here is that we must call continuation.resume() exactly once in the withCheckedContinuation block. If we forget to do it, our app would be blocked forever. If we do it twice, the app would crash.

Swift provides another function to deal with APIs that can return an error state. Let's say we have the following function:

func oldAPI(completion: (Result<[Post], Error>) -> Void)

We can use the withCheckedThrowingContinuation function to transform it into a function with the following signature

func newAPI() async throws -> [Post]

The withCheckedThrowingContinuation function follows the same pattern as the withCheckedContinuation, as you can see from the following code

func newAPI() async throws -> [Post] {
    try await withCheckedThrowingContinuation { continuation in
        oldAPI { result in
            switch result {
                case .success(let value):
                    continuation.resume(returning: value)
                case .failure(let error):
                    continuation.resume(throwing: error)
            }
    }
}

Grouping asynchronous calls

Have a look at following code

let value1 = try await model.getValue1()
let value2 = try await model.getValue2()

Both calls are asynchronous and could happen at the same time. However, by explicitly marking them with await, the call to getValue2() doesn’t start until the call to getValue1() completes.

Sometimes, you need to perform sequential asynchronous calls — like when you want to use data from the first call as a parameter of the second call.

Swift offers a special syntax that lets you group several asynchronous calls and await them all together.

do {
    async let value1 = try model.getValue1()
    async let value2 = try model.getValue2()
    let (value1Result, value2Result) = try await (value1, value2)
} catch {
    errorMessage = error.localizedDescription
}

An async let binding allows you to create a local constant that’s similar to the concept of promises in other languages.

To get the binding results, you need to use await. If the value is already available, you’ll get it immediately. Otherwise, your code will suspend at the await until the result becomes available.

To group concurrent bindings and extract their values, you have two options:

  • Group them in a collection, such as an array.
  • Wrap them in parentheses as a tuple and then destructure the result.

async let approach has one restriction, it can't run a variable number of tasks at the same time, we have to to await the results.

A Task Group is a form of structured concurrency designed to provide a dynamic amount of concurrency. With it, we can launch multiple tasks, launch them in a group, and have them execute all at the same time.

There are a few task group behaviors that we need to be aware of:

  1. A task group consists of a collection of asynchronous tasks (child tasks) that is independent of each other.
  2. When child tasks are added to a group, they begin executing immediately and concurrently.
  3. We cannot control when a child task finishes its execution.
  4. A task group only returns when all of its child tasks finish their execution.
  5. A task group can exit by either returning a value, returning void (non-value-returning), or throwing an error.

To create a task group, we either call withTaskGroup or withThrowingTaskGroup, depending on whether we’d like to have the option to throw errors within our tasks.

Let’s create a simple image loader that asynchronously returns a dictionary of images keyed by the URLs that they were downloaded

func loadImages(from urls: [URL]) async throws -> [URL: UIImage] {
    try await withThrowingTaskGroup(of: (URL, UIImage).self) { group in
        for url in urls {
            group.addTask{
                let image = try await self.loadImage(from: url)
                return (url, image)
            } 
        }

        var images = [URL: UIImage]()

        for try await (url, image) in group {
            images[url] = image
        }

        return images
    }
}

All child tasks will run concurrently and we have no control over when they will finish running. In order to collect the result of each child task, we must loop through the task group. Notice we are using the await keyword when looping to indicate that the for loop might suspend while waiting for the child task to complete. Every time when a child task returns, the for loop will iterate and update the images dictionary.

Canceling an async task

Canceling unneeded tasks is essential for the concurrency model to work efficiently.

If you write your async code inside a .task{} view modifier in SwiftUI, it will be responsible for automatically canceling your code when the view disappears. But the actions in some button aren’t in a .task{}, so there’s nothing to cancel your async operations.

To fix this issue, you’ll manually cancel your task. Start by adding a new state property

@State var someTask: Task<Void, Error>?

In someTask, you’ll store an asynchronous task that returns no result and could throw an error. Task is a type like any other, so you can also store it in your view, model or any other scope. Task doesn’t return anything if it’s successful, so success is Void; likewise you return an Error if there’s a failure.

Next, assign your task task to someTask

someTask = Task {
    ... code ...
}

This stores the task in someTask so you can access it later. Most importantly, it lets you cancel the task at will.

You’ll cancel the task when the user navigates back to the previous screen. Add following code in .onDisappear or viewWillDisappear.

// SwiftUI
.onDisappear {
    someTask?.cancel()
}

// UIKit
override func viewWillDisappear(_ animated: Bool) {
    super.viewWillDisappear(animated)
    someTask?.cancel()
}

Canceling someTask will also cancel all its child tasks — and all of their children, and so forth.