Async / Await in Swift

Async / Await in Swift

Apple introduced the concept of async/await in Swift 5.5 and announced it in 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 (Beta for now). So please make sure to download latest Xcode version before proceeding

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 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 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
}

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 function. In order to do that, first, you need to create a task and call 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)
}

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 functions in parallel (And get results at once)

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, _) = try await URLSession.shared.data(for: imageRequest)
    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. However, at the end where we are waiting for the result will get blocked until results from all 3 calls are received or any of them raises an exception.

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, _) = try await URLSession.shared.data(for: imageRequest)
    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

Caveat:
Please note that only read-only properties can be async. If you have any property which is writable, unfortunately, it cannot be marked as async

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, starting 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?

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 may 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 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.

Until next time...

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