Async / Await in Swift

Async / Await in Swift

Apple introduced the concept of async/await in Swift 5.5 and announced it in the WWDC21 session. Today, we are going to see it in action and how you can leverage it to write readable async code in your app.

Please note that async/await is only available in Swift 5.5 and Xcode 13. So please make sure to download latest Xcode version before proceeding with this tutorial

Introduction

async/await construct follows the concept of structured concurrency. Meaning, for any code written using async/await follows the structural sequential pattern unlike how closures work. For example, you might be calling a function passing the closure parameters. After calling the async function, the flow will continue or return. Once the async task is done, it will call closure in the form of a completion block. Here, the program flow and closure completion flow was called at different times breaking the structure, thus this model is called unstructured concurrency.

Structured concurrency makes code easier to read, follow and understand. Thus, Apple is aiming at making code more readable by adopting the concept of async/await starting Swift 5.5. Let's start learning by looking at how async code looks before async/await and how we can refactor it to use this new feature.

Getting Started

Let's say we have an async function named saveChanges which saves our fictitious changes and calls the completion callback after a few seconds,

enum DownloadError: Error {
    case badImage
    case unknown
}

typealias Completion = (Result<Response, DownloadError>) -> Void

func saveChanges(completion: Completion) {
    Thread.sleep(forTimeInterval: 2)
    
    let randomNumber = Int.random(in: 0..<2)
    
    if randomNumber == 0 {
		completion(.failure(.unknown))
        return
    }
    completion(.success(Response(id: 100)))
}

// Calling the function
saveChanges { result in
    switch result {
    case .success(let response):
        print(response)
    case .failure(let error):
        print(error)
    }
}
// Following code

This is the async code with classic completion closure. However, here you can see a few problems and we realize that there is a scope for improvement,

  1. This code is unstructured.  We are calling saveChanges and then continue executing the following code on the same thread. When changes are saved in async style, the completion closure is called and we get the result in the callback and we proceed with them. However, this code is unstructured and thus is difficult to follow
  2. Inside the saveChanges function, we are calling completionin two different places. However, things can get out of control if we need to call completion in multiple places. If we miss any of them somewhere, the function will fail to raise an error, and the caller will get stuck waiting for either success or failure case

Rewriting code using async/await

Let's try to refactor this code to use async/await. Below are some steps we are going to follow.

  1. Mark function with async keyword. This is done by adding async keyword at the end of a function name in the function definition
  2. If the async function is going to raise an error, also mark it with throws keyword which follows the async keyword
  3. Have the function return the success value. Errors will be handled in do-catch block at caller side in case callee throws an error
  4. Since the function is marked as async, we cannot call it directly from synchronous code. We will wrap it into Task where it will execute parallelly on the background thread
// Refactored function
func saveChanges() async throws -> Response {
    Thread.sleep(forTimeInterval: 2)
    
    let randomNumber = Int.random(in: 0..<2)
    
    if randomNumber == 0 {
        throw DownloadError.unknown
    }
    
    return Response(id: 100)
}

// Calling function

func someSyncFunction() {
    // Beginning of async context
    Task(priority: .medium) {
    do {
        let result = try await saveChanges()
           print(result.id)
        } catch {
            if let downloadError = error as? DownloadError {
                // Handle Download Error
            } else {
                // Handle some other type of error
            }
        }
    }
    
    // Back to sync context
}

What are Tasks?

Tasks provide a new async context for executing code concurrently. Developers can wrap the async operation in a self-contained block or closure and use it to run multiple async tasks simultaneously. You can create a new task for each async operation, thus providing fresh execution context every time a new task is created.

No matter how many tasks are created, iOS schedules them to run in parallel whenever it is safe and efficient to do so. Since they are deeply integrated into the iOS concurrency ecosystem, Swift automatically takes care of preventing concurrency bugs during runtime.

Please note that running async function does not automatically create a new task, you need to create a new task explicitly and wrap it around the your async code

How does the code with completion-closure differ from async/await while handling failures?

The classic completion closure code uses either Result type or passes (Result, Error) pair in completion closure

typealias Completion_Result_Type = (Result<Response, Error>) -> Void
typealias Completion_Pair_Type = (Response, Error) -> Void

However, as I noted above, there could be cases where the function may fail to call completion closure leaving the caller hanging. This will result in an infinite loading spinner or undefined UI state.

The async/await code rather relies on exceptions. If something goes wrong, it reacts by throwing the exception. Even if some code that you don't directly control fails, the caller can detect failure when the callee throws an exception. This way functions written using async/await construct only need to return a valid value. If it runs into an error, it will rather end up throwing an exception.

Bridging sync/async code together

Unfortunately, async code cannot be directly called from the synchronous method. In order to do that, first, you need to create a task and call the async function from it.

func someSynchronousFunction() {
    Task(priority: .medium) {
        let response = await saveChanges()
        // Following code
    }
    // Synchronous code continues
}

func saveChanges() async -> Response {
    // Some async code
    return Response(id: 100)
}

While creating a task, it's important to specify the priority too based on how urgent the task is. The lowest it can go is background where the task can continue in the background where it can perform the operation not so urgent from the user's perspective.

If the operation performed in the task is important enough and the user is waiting for the result, you can specify the priority as userInitiated which indicates that the operation performed in the task is important to the user.

The default task priority is medium where it will be treated the same way as other operations. If more resources are available, it might be bumped up. If many services are competing for resources, the task might get deprioritized in the queue

The possible task priority options are as follows,

  1. high
  2. medium
  3. low
  4. userInitiated
  5. utility
  6. background

An alternative way to invoke async code from the synchronous method

Creating a task and executing a code from inside is not the only way to execute async-await code. You can also wrap the code inside async closure and call the async function just like you would call inside Task.


func someSynchronousFunction() {
    async {
        let response = await saveChanges()
        // Following code
    }
    // Synchronous code continues
}

func saveChanges() async -> Response {
    // Some async code
    return Response(id: 100)
}

Using defer inside the async context

The asynchronous task that executes in the Task or async closure is alive in the context of closure. As soon as the async task finishes, it exits the closure too. If you need to perform cleanup or free resources before exiting the async context, wrap that code in a defer block.

The defer block gets executed the last before exiting the context and guarantees to be executed making sure resource cleanup is not overlooked.


async {
	defer {
    	// Cleanup Code
    }
	// async code
}

// OR

Task(priority: .medium) {
	defer {
    	// Cleanup Code
    }
	// async code
}

Running multiple async functions in parallel

If you need to run multiple unrelated async functions in parallel, you can wrap them up in their own tasks which will run in parallel. The order in which they execute is undefined, but rest assured, they will keep executing in parallel while the synchronous code outside of the task context will keep executing in a serial fashion

Task(priority: .medium) {
	let result1 = await asyncFunction1()
}

Task(priority: .medium) {
	let result2 = await asyncFunction2()
}

Task(priority: .medium) {
	let result3 = await asyncFunction3()
}

// Following synchronous code

In the example above, we have created 3 tasks to execute async functions which will run in parallel.

Passing the result of async function to the next function

await/async allows us to wait on the async function until it returns the result (Or throws an error) and pass it onto the next function. That way, it's better at reflecting intent by defining the order of execution,

func getImageIds(personId: Int) async -> [Int] {
    // Network call
    Thread.sleep(forTimeInterval: 2)
    return [100, 200, 300]
}

func getImages(imageIds: [Int]) async -> [UIImage] {
    // Network call to get images
    Thread.sleep(forTimeInterval: 2)
    let downloadedImages: [UIImage] = []
    return downloadedImages
}

// Execution
Task(priority: .medium) {
    let personId = 3000
    // Wait until imageIds are returned
    let imageIds = await getImageIds(personId: personId)
    
    // Continue execution after imageIds are received
    let images = await getImages(imageIds: imageIds)

    //Display images
}

Running multiple async operations in parallel and getting results at once (Concurrent Binding)

If you have multiple unrelated async functions, you can make them run in parallel. That way, the next task won't get blocked until the preceding task is done especially when both of them are unrelated.

For example, consider the case where you need to download 3 images and each of them is identified by a unique identifier. You can have them execute in parallel and receive the result containing 3 images at once at the end. The time it takes to return the result is equal to the amount of time it takes to execute the longest task

In order to take advantage of this feature, you can precede the result of the async task with async let keyword. That way you are letting the system know that you want to run it in parallel without suspending the current flow. Once you trigger the parallel execution, you can wait for all the results to come back at once using await keyword.


// An async function to download image by Id
func downloadImageWithImageId(imageId: Int) async throws -> UIImage {
    let imageUrl = URL(string: "https://imageserver.com/\(imageId)")!
    let imageRequest = URLRequest(url: imageUrl)
    let (data, response) = try await URLSession.shared.data(for: imageRequest)
    
        if let httpResponse = response as? HTTPURLResponse, httpResponse.statusCode != 200 {
    	throw DownloadError.invalidStatusCode
    }
    
    guard let image = UIImage(data: data) else {
        throw DownloadError.badImage
    }
    return image
}

// Async task creation
Task(priority: .medium) {
    do {
        // Call function and proceed to next step
        async let image_1 = try downloadImageWithImageId(imageId: 1)
        
        // Call function and proceed to next step
        async let image_2 = try downloadImageWithImageId(imageId: 2)
        
        // Call function and proceed to next step
        async let image_3 = try downloadImageWithImageId(imageId: 3)
        
        let images = try await [image_1, image_2, image_3]
        // Display images
        
    } catch {
        // Handle Error
    }
}

In the above example, since we annotated the result with async let keyword, the program flow will not block and continue to call three functions in parallel.  This strategy is called concurrent bindings where program flow isn't blocked and continue to flow. At the end where we are waiting for the result from async task, we will get blocked until results from all 3 calls are received or any of them raises an exception as indicated by try keyword.

async/await URLSession - How Apple changed some of their APIs to make them consistent with the new await/async feature

In order to adapt to the new change, Apple also changed some of its APIs. For example, an earlier version of URLSession used the completion block to signal caller until the network-heavy operation is done,

- (NSURLSessionDataTask *)dataTaskWithRequest:(NSURLRequest *)request completionHandler:(void (^)(NSData * _Nullable data, NSURLResponse * _Nullable response, NSError * _Nullable error))completionHandler;

However, starting Swift 5.5 and iOS 15.0, Apple changed its signature to utilize the async/await feature

public func data(for request: URLRequest, delegate: URLSessionTaskDelegate? = nil) async throws -> (Data, URLResponse)

Let's see a demo of the new API in action. We will use a similar example to download the image given the identifier. We will define the code in the async function and pass the id of the image to download. We will use new async API to get the image data and return UIImage object. (Or throw an exception in case something goes wrong)

// Function definition
func downloadImageWithImageId(imageId: Int) async throws -> UIImage {
    let imageUrl = URL(string: "https://imageserver.com/\(imageId)")!
    let imageRequest = URLRequest(url: imageUrl)
    let (data, response) = try await URLSession.shared.data(for: imageRequest)
    
    if let httpResponse = response as? HTTPURLResponse, httpResponse.statusCode != 200 {
    	throw DownloadError.invalidStatusCode
    }
    
    guard let image = UIImage(data: data) else {
        throw DownloadError.badImage
    }
    return image
}

// Download image by Id
let image = try await downloadImageWithImageId(imageId: 1)
// Display image

Async properties

Not only methods, but properties can be async too. If you want to mark any property as async, this is how you can do it.

extension UIImage {
    var processedImage: UIImage {
        get async {
            let processId = 100
            return await self.getProcessedImage(id: 100)
        }
    }
    
    func getProcessedImage(id: Int) async -> UIImage {
        // Heavy Operation
        return self
    }
}

let originalImage = UIImage(named: "Flower")
let processed = await original?.processedImage

In the above example,

  1. We have an async property processedImage on UIImage
  2. The getter for this property calls another async function getProcessedImage which takes processId as input and returns the processed image back
  3. We are assuming getProcessedImage performs a heavy operation and thus its wrapped in an async context
  4. Given the original image, we can get the processed image by querying async property processedImage on it and awaiting the result
  5. Async properties also support the throws keyword

Please note that only read-only properties can be async. If you have any property that is writable, unfortunately, it cannot be marked as async. Meaning, referencing the above example, if you try to provide a setter for the async property, the compiler will raise an error

async properties that throw

In addition to async support, the property can throw an error too. The only change is, you need to add throws keyword after async keyword in property definition and use try await with the method that is responsible for throwing an error.


extension UIImage {
    var processedImage: UIImage {
        get async throws {
            let processId = 100
            return try await self.getProcessedImage(id: processId)
        }
    }
    
    func getProcessedImage(id: Int) async throws -> UIImage {
        // Heavy Operation
        
        // Throw an error is operation encounters exception
        
        return self
    }
}


let originalImage = UIImage(named: "Flower")
let processedImage = try await original?.processedImage

Canceling async task

Once the app kicks off the async task, there could be situations where it no longer makes sense to continue with it. In such cases, it can manually cancel the async task in progress and all the child tasks in the canceled task will be canceled too.

Whether the async task is created with the Task API or using async closure, it returns the instance of Task back to the caller. The caller can store this instance and call its cancel method if the task hasn't been completed yet and they want to cancel the execution.

cancel is important to conserve the memory when the current instance is deallocated. You can detect the deallocation in deinit method and as soon as this method is called, you can call cancel on any async task that hasn't been completed yet.


let task = Task(priority: .background) {
    let networkService = NetworkOperation()
    let image = try await networkService.downloadImage(with: 1)
}

let asyncTask = async {
    let networkService = NetworkOperation()
    let image = try await networkService.downloadImage(with: 1)
}


// Probably cancel task in the deinit method 
// when current instance is deallocated

deinit {
	task.cancel()
    asyncTask.cancel()
}
For practical cases, you can store the instance of Task in a class-level variable so that its alive for the lifetime of a class. At any point in the future, task needs to be cancelled, it can be cancelled just by calling cancel API on the Task instance

Unit testing async/await code

Finally, we will talk about unit testing async/await code. Before async/await, when you wanted to test async code, you had to set up expectations, wait for them to fulfill, wait for the completion block to return, and fulfill the expectation. However, beginning with async/await, we can do it in a much simpler way.

If you've written async code before async/await, the testing would look something like this,

func saveChanges(completion: (Response, Error?) -> Void) {
    // Internal code
}

func testMyModel() throws {
	let expectation = XCTestExpectation(description: "Some expectation description")

    let mockViewModel = ....
    mockViewModel.saveChanges { response, error in
        XCTAssertNotNil(error)
        expectation.fulfill()
    }

    wait(for: [expectation], timeout: 2.0)
}

This is a lot of code to verify one thing. Let's try to rewrite saveChanges using async/await code and see how it affects our testing,

func saveChanges() async throws -> Response {
    // Either return Response of throw an error
}

func testMyModel() async throws {
    let mockViewModel = .....
    XCTAssertNoThrow(Task(priority: .medium) {
		try await mockViewModel.saveChanges()
    })
}

The code has been reduced to just a few lines and there is no more boilerplate. How awesome is that?

How is it optimized for the app performance?

When the await operation is in progress, the related async function is suspended, not run, set aside and program flow at that point does not proceed until that function completes or the error is thrown. Since await function executes in its own async task, it does not affect the app responsiveness while waiting for theasync result.

An async function does not use any resources while it's in suspended mode and it's not blocking the thread from which it was called. This feature allows Swift runtime to reuse the thread from which async function was called for other purposes. Due to this reusability, if you have many async functions running, it allows the system to allocate very few threads for a large number of async operations.

Once the await function finishes either with data or an error, it lets the system know that it has finished its work, and the system can resume its execution and pass the result, or the error to the calling function.

Using unstructured tasks

In addition to structured concurrency, Swift also makes a provision to execute unstructured tasks. There are many cases where tasks need not follow the current hierarchy and can execute in their own context.

Detached Tasks

Detached tasks are one example of unstructured tasks which provide maximum flexibility. They are independent tasks and their lifetime is not attached to the originating scope. They need not run with the same priority of the context from which they were launched, but their priority can be controlled by passing appropriate parameters when they are created.

An example of this could be, downloading and locally storing images. Even though downloading images is an async task and needs to return the result with downloaded images back to the caller, the app may need to store images in local storage. In this case, even though images are downloaded and the async function returns the result to the caller, the task that stores these images into the local storage can continue running in the background mode in a completely different context with its own priority level.

Let's see the example below with task detached from the current context and running in the background,


Task(priority: .userInitiated) {
    
    let networkOperation = NetworkOperation()
    print("About to download image")
    let image = try await networkOperation.downloadImage(with: 1)
    print("Image downloaded")
    
    print("Starting detached task in the background")
    Task.detached(priority: .background) {
        // Locally store the downloaded image in the cache
    }
    
    print("Image size is")
    print(image.size)
}

In the above example, we're running the task to download the image by id with a priority level of userInitiated. Once the image is downloaded, we print its size and potentially use it for a follow-up operation.

In between, we also detached from the current context using Task.detached API. We're saying, once the image is downloaded, continue the next steps as is, but to locally store the image, detach from the current context so that the task of locally saving images can run in the background without disturbing the user activities.


Takeaways

Coming from the background of future and promises, ReactiveCocoa, and completion closures, this is definitely a new thing for me. I was surprised by this novel approach, but probably Javascript folks who have had experience with async/await for a long time probably won't. I like how async enables the function to suspend and also suspend its caller only to resume the execution later. From the programmer's perspective, I think this new structure is better to reflect intent through structured concurrency whereas, with completion closures, I had to continuously change the context since you would call the function with completion closure at one point in time and it will return the value at another.

Unfortunately, since this is a relatively new change, I am afraid I won't be using it in the production app any time soon. But as I continue with my side-projects,  I will definitely be switching to async/await instead of relying on classic completion closures.


This is all I have in Swift concurrency today. Hope you liked this post. If you have any other thoughts or comments, please feel free to contact me on Twitter @jayeshkawli.

References:

https://www.advancedswift.com/async-await/
https://www.andyibanez.com/posts/understanding-async-await-in-swift/
https://www.avanderlee.com/swift/async-let-asynchronous-functions-in-parallel/
https://wwdc.io/share/wwdc21/10132