Running async network operations in parallel on iOS using async-await

Running async network operations in parallel on iOS using async-await
Photo by Denis Chick / Unsplash

Running parallel network operations on iOS was not always a smooth process. I remember that at my previous jobs, developers opted to use either third-party libraries or DispatchGroup to trigger parallel requests.

These solutions had their own pros and cons. Using DispatchGroup in the code was tedious and its block-based approach was confusing. Not to mention, anyone reading that code for the first time would have a hard time understanding it and themselves asking a question like this,

The third-party approach had its own problems. Usually, large companies I have worked for don't prefer to use third-party libraries every time they need a solution. Granted, there are good networking libraries like Alamofire and PromiseKit that make networking easy on the iOS platform, but unless they offer other benefits, you can't justify their usage just for making parallel requests.

Enter Swift 5.5 and Meet async-await

Swift 5.5 introduces a new concept of async-await which makes async programming feel like a cakewalk. It replaces the traditional block-based async style with a simple async-await flow. With the ability to combine multiple async operations, you can also fire them parallelly which was cumbersome with the earlier approach using closures for async operations.

We will take a look at both variants - One for serial operations where the current operation depends on the result provided by the preceding operation and the other where multiple unrelated operations can run in parallel.

If you are new to async-await in Swift, I will strongly recommend referring to async-await in Swift article. This article provides an introduction to this new feature and is a great introductory start for this blog post

Part 1 - async function to download images from a given URL

To get started, let's focus on the async function which downloads the image at the given URL and returns the UIImage object. If download operation fails for any reason, the method throws an error,


enum ImageDownloadError: Error {
    case badURL
    case badHTTPResponse
    case imageNotFound
}

class NetworkOperation {
    
    let urlSession: URLSession
    
    init() {
        urlSession = URLSession.shared
    }
    
    // Referenced from https://developer.apple.com/videos/play/wwdc2021/10192
    func downloadImage(with imageNumber: Int) async throws -> UIImage {
        
        let urlString = "https://jayeshkawli.ghost.io/content/images/size/w1600/2022/02/\(imageNumber).jpg"
        
        guard let url = URL(string: urlString) else {
            throw ImageDownloadError.badURL
        }

        let request = URLRequest(url: url, cachePolicy: .reloadIgnoringCacheData)
            
        let (data, response) = try await urlSession.data(for: request)
        
        if let httpResponse = response as? HTTPURLResponse, httpResponse.statusCode != 200 {
            throw ImageDownloadError.badHTTPResponse
        }
        
        guard let image = UIImage(data: data) else {
            throw ImageDownloadError.imageNotFound
        }
        
        return image
    }
}
Images are stored at https://jayeshkawli.ghost.io/content/images/size/w1600/2022/02/\(imageNumber).jpg where each image is referenced by imageNumber we pass to the downloadImage method.

What's happening in the above code?

NetworkOperation class provides a utility to download images by the given imageNumber. If everything goes as expected, urlSession.data(for: request) method returns a data and response. If the response code is valid, we take the data and construct UIImage object out of it and return it from the method.

Throwing an error

There is a lot that can go wrong insidedownloadImage method. For example,

  1. We're unable to form a valid URL instance from passed urlString
  2. The server returned an invalid HTTP response code
  3. We're unable to get a valid UIImage object from data received from the server

If any of these things happen, we throw an appropriate error back to the caller of this method.

Why async tasks are good?

Whenever the function call is accompanied by await keyword, the function is suspended, set aside, and not run until either the operation is complete or throws an error. In the above case, as soon as we call urlSession.data(for: request), the operation is suspended by the Swift runtime while the operation to download the data continues in the background.

In case of a successful response, the data is passed to the next steps to be converted into UIImage object and in case of thrown error, the error is passed back to whoever called this function in the first place.

async operation does not use any resources while being in the suspended mode and it's not blocking a thread. This allows Swift runtime to use the thread function it was previously running on for other operations which in turn allows for thread reusability as well as reduced system memory consumption.

Part 2 - Running async-await operations in parallel

In order to demonstrate parallel operations, we will use NetworkService to download three images identified by image sequence. For example, images can be downloaded parallelly with the following syntax. Since download operations are unrelated to each other, we can run them in parallel without risking data corruption.


let networkService = NetworkOperation()

async let firstImage = networkService.downloadImage(with: 1)
async let secondImage = networkService.downloadImage(with: 2)
async let thirdImage = networkService.downloadImage(with: 3)

let allImages = try await (firstImage, secondImage, thirdImage)
The async-let keyword allows related code to run in parallel until we try to use the result. Since image download operations are marked with async let, Swift runtime will suspend try await on the last line until these async operations are ready. While being in the suspended mode, all three requests are fired simultaneously and the system waits until the latest request returns.

Since async-await operations run in the background in suspended mode, they cannot be run inside the synchronous function. Instead, we will run them inside Task where they will run parallelly in the background.


typealias NamedImages = (firstImage: UIImage, secondImage: UIImage, thirdImage: UIImage)

Task(priority: .userInitiated) {
    let networkService = NetworkOperation()
    
    async let firstImage = networkService.downloadImage(with: 1)
    async let secondImage = networkService.downloadImage(with: 2)
    async let thirdImage = networkService.downloadImage(with: 3)
    
    let startTime = CFAbsoluteTimeGetCurrent()
    
    let allImages: NamedImages = try await (firstImage, secondImage, thirdImage)
    
    let endTime = CFAbsoluteTimeGetCurrent()
    
    print(String(format: "%.5f", endTime - startTime))
}
Average time it takes to download all three images simultaneously over 3G network - 3.786 seconds

If I take a look at Charles proxy network debugger, it shows all 3 requests fired simultaneously and the total wait time taken by await operation is equal to the request with the longest response time. Since requests are fired simultaneously, there is no definite order on the request sequencing.

And here is the demo,

The app is run on the 3G network to highlight download operations explicitly
0:00
/

Part 3 - Running async-await operations in serial

The power of async-await is not limited to just parallel operations. In the previous example, we were able to run image download operations in parallel because they had virtually no dependencies between them.

But imagine a case where these operations are dependent on each other and the result of the current operation will be used for the next operation. In such cases, we will choose to run these operations in a serial manner.

Let's refactor the earlier code to make async operations serial. I am going to take out async keyword used on the left side and replace it with try await on the right side.


Task(priority: .userInitiated) {
    let networkService = NetworkOperation()
    
    let startTime = CFAbsoluteTimeGetCurrent()
    
    let firstImage = try await networkService.downloadImage(with: 1)

    let secondImage = try await networkService.downloadImage(with: 2)

    let thirdImage = try await networkService.downloadImage(with: 3)

    let endTime = CFAbsoluteTimeGetCurrent()
    
    print(String(format: "%.5f", endTime - startTime))
}

As you may have guessed correctly, since each download operation waits until the previous operation completes, this flow takes a longer time than requests running in parallel.

If I inspect the outgoing requests with Charles proxy, the requests always execute serially and there is a definite order,

And here is the fancy demo of executing serial download operations on the 3G network using async-await,

0:00
/
When tested on 3G network, 3 download operations running in serial fashion will take on an average of 4.2 seconds to complete

Why prefer Parallel Operations over Serial Operations

Unless there is a dependency between successful async operations, it is recommended to use parallel operations to fetch the data. With parallel operations, all the requests fire simultaneously and the amount of time it takes for the async function to return control back to executing thread is equal to the amount of time taken by the slowest request.

On the contrary, serial operations execute sequentially and the total time taken by them is equal to the sum of response time for all the requests which can be significant as the number of requests increases.

Summary

It's a welcome change that the Swift 5.5 async-await construct now supports parallel operations through structured concurrency.  Developers can now use async-let to force independent operations to run in parallel and suspend the running thread until they return either with success or an error. You neither have to depend on complicated DispatchGroups nor do you have to incorporate a third-party library in your app in order to just support parallel requests.

I can't wait to use this new construct in my side-projects to replace closure-based async operations with async-await wherever it's applicable and most importantly, makes sense. If you have your own stories about dealing with a closure-based approach or how you would like to use this new Swift 5.5 feature, do reach out to me on Twitter @jayeshkawli.

References:

What‘s new in Swift - WWDC21 - Videos - Apple Developer
Join us for an update on Swift. Discover the latest language advancements that make your code easier to read and write. Explore the...