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
- Getting Started
- Rewriting code using async/await
- How does code with completion-closure differs from async/await while handling failures?
- Bridging sync/async code together
- Running multiple async functions in parallel
- Passing result of the async function to the next function
- Running multiple async functions in parallel (And get results at once)
- How is it optimized for the app performance
- async/await URLSession - How Apple changed some of their APIs to make it consistent with the new await/async feature
- Async properties
- Canceling async task
- Unit testing async/await code
- Using unstructured tasks
- Takeaways
- References
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,
- This code is unstructured. We are calling
saveChanges
and then continue executing the following code on the same thread. When changes are saved inasync
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 - Inside the
saveChanges
function, we are callingcompletion
in two different places. However, things can get out of control if we need to callcompletion
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.
- Mark function with async keyword. This is done by adding
async
keyword at the end of a function name in the function definition - If the async function is going to raise an error, also mark it with
throws
keyword which follows theasync
keyword - Have the function return the success value. Errors will be handled in
do-catch
block at caller side in case callee throws an error - Since the function is marked as
async
, we cannot call it directly fromsynchronous
code. We will wrap it intoTask
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 runningasync
function does not automatically create a new task, you need to create a new task explicitly and wrap it around the yourasync
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,
high
medium
low
userInitiated
utility
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,
- We have an async property
processedImage
onUIImage
- The getter for this property calls another async function
getProcessedImage
which takesprocessId
as input and returns the processed image back - We are assuming
getProcessedImage
performs a heavy operation and thus its wrapped in an async context - Given the original image, we can get the processed image by querying async property
processedImage
on it and awaiting the result 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 ofTask
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 callingcancel
API on theTask
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