Running async network operations in parallel on iOS using async-await
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 athttps://jayeshkawli.ghost.io/content/images/size/w1600/2022/02/\(imageNumber).jpg
where each image is referenced byimageNumber
we pass to thedownloadImage
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,
- We're unable to form a valid
URL
instance from passedurlString
- The server returned an invalid HTTP response code
- We're unable to get a valid
UIImage
object fromdata
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 withasync let
, Swift runtime will suspendtry 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
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,
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 DispatchGroup
s 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.