Async/Await in Swift — Detailed Code Examples

Async/Await in Swift — Detailed Code Examples

Introduction

async-await is part of the structured concurrency changes in Swift 5.5 that were presented during WWDC 2021. Concurrency in Swift means allowing multiple pieces of code to run at the same time. This is a very simplified description, but it should give you an idea of ​​how important concurrency in Swift is to the performance of your app. With the new async methods and await statements, we can define methods to do asynchronous work.

You may have read the Swift Concurrency Manifesto by Chris Lattner[1], which was published a few years ago. Many developers in the Swift community are excited about the future of a structured way to define asynchronous code. Now that it’s finally here, we can simplify our code with async-await, making our asynchronous code easier to read.

What is async?

Async means asynchronous and can be seen as an attribute that explicitly indicates that a method is to perform asynchronous work. An example of such a method looks like this:

 func fetchImages ( ) async throws -> [ UIImage ] {
// .. perform data request
}

The fetchImages method is defined as asynchronous and throwable, which means it is performing an asynchronous job that can fail. If all goes well, the method will return an array of images, or throw an error if something went wrong.

How async replaces completion callback closure

The async method replaces the often seen completion callback. Completion callbacks are very common in Swift and are used to return from asynchronous tasks, usually combined with a result type parameter. The above method would generally be written like this:

 func fetchImages ( completion : ( Result < [ UIImage ] , Error > ) -> Void ) {
// .. perform data request
}

In today's Swift version, it is still possible to define methods using completion closures, but it has some disadvantages that async can solve.

You must make sure you call the completion closure in every possible exit method. Failure to do so may cause your application to wait endlessly for a result.

Closure code is harder to read. Reasoning about execution order is not as easy compared to structured concurrency.

Weak references are needed to avoid circular references.

The implementer needs to switch the result to get the result. Try catch statement cannot be used from the implementation level.

These shortcomings are based on using the closure version of the relatively new Result enum. It's likely that many projects are still using completion callbacks instead of this enum:

 func fetchImages ( completion : ( [ UIImage ] ? , Error? ) -> Void ) {
// .. perform data request
}

Defining a method like this makes it hard to reason about the result on the caller side. Both value​ and error are optional, requiring us to unwrap them in every case. Unwrapping these optionals leads to more cluttered code, which doesn't help readability.

What is await?

await is a keyword used to call asynchronous methods. You can think of them (async-await) as best friends in Swift, because one never leaves the other, and you can basically say:

"Await is waiting for a callback from its partner async"

Although this sounds naive, it's not deceptive! We can see an example by calling the asynchronous fetchImages method we defined earlier:

 do {
let images = try await fetchImages ( )
print ( "Fetched \(images.count) images." )
} catch {
print ( "Fetching images failed with error \(error)" )
}

It may be hard to believe, but the code example above is performing an asynchronous task. Using the await​ keyword, we tell our program to wait for the result of the fetchImages method and continue only after the result arrives. This could be a collection of images, or it could be an error indicating that something went wrong while fetching the images.

What is structured concurrency?

Structured concurrency using async-await method calls makes it easier to reason about the order of execution. Methods are executed linearly, without the back-and-forth that closures require.

To better explain this, we can look at how we would call the above code example before structured concurrency came along:

 // 1. Call this method
fetchImages { result in
// 3. Asynchronous method content return
switch result {
case .success ( let images ) :
print ( "Fetched \(images.count) images." )
case .failure ( let error ) :
print ( "Fetching images failed with error \(error)" )
}
}
// 2. End of calling method

As you can see, the calling method ends before the image is acquired. Eventually, we receive a result, and then we return to the flow of the completion callback. This is an unstructured execution order that can be difficult to follow. If we execute another asynchronous method in the completion callback, this will undoubtedly add another closure callback:

 // 1. Call this method
fetchImages { result in
// 3. Asynchronous method content return
switch result {
case .success ( let images ) :
print ( "Fetched \(images.count) images." )

// 4. Call the resize method
resizeImages ( images ) { result in
// 6. Resize method returns
switch result {
case .success ( let images ) :
print ( "Decoded \(images.count) images." )
case .failure ( let error ) :
print ( "Decoding images failed with error \(error)" )
}
}
// 5. Get the image method return
case .failure ( let error ) :
print ( "Fetching images failed with error \(error)" )
}
}
// 2. End of calling method

Each closure adds another level of indentation, making it harder to understand the order of execution.

The role of structured concurrency is best explained by rewriting the above code example using async-await.

 do {
// 1. Call this method
let images = try await fetchImages ( )
// 2. Get the image method to return

// 3. Call the resize method
let resizedImages = try await resizeImages ( images )
// 4. Resize method returns

print ( "Fetched \(images.count) images." )
} catch {
print ( "Fetching images failed with error \(error)" )
}
// 5. End of calling method

The order of execution is linear, so it is easy to understand and reason about. It is easier to understand asynchronous code when we are still performing complex asynchronous tasks.

Calling asynchronous methods

Calling an asynchronous method in a function that does not support concurrency

When using async-await for the first time, you may encounter errors like this.

This error occurs when we try to call an asynchronous method from a synchronous calling environment that does not support concurrency. We can resolve this error by defining our fetchData method as asynchronous as well:

 func fetchData ( ) async {
do {
try await fetchImages ( )
} catch {
// .. handle error
}
}

However, this will move the error to another place. Instead, we can use the Task.init method, call the asynchronous method from a new task that supports concurrency, and assign the result to a property in our view model:

 final class ContentViewModel : ObservableObject {

@Published var images : [ UIImage ] = [ ]

func fetchData ( ) {
Task .init {
do {
self .images = try await fetchImages ( )
} catch {
// .. handle error
}
}
}
}

Using async methods with trailing closures, we create an environment in which we can call the async method. Once the async method is called, the method that fetches the data returns, and all subsequent async callbacks occur within the closure.

Using async-await

Adopting async-await in an existing project

When adopting async-await in an existing project, you want to be careful not to break all your code at once. When doing a large-scale refactoring like this, it's best to consider temporarily maintaining the old implementation so that you don't have to update all your code before you know whether the new implementation is stable enough. This is similar to the deprecation approach used by many different developers and projects in the SDK.

Obviously, you’re not obliged to do this, but it can make it easier to try out async-await in your projects. On top of that, Xcode makes it super easy to refactor your code and also provides an option to create a separate async method:

Each refactoring method has its own purpose and results in different code transformations. To better understand how it works, we will use the following code as input for the refactoring:

 struct ImageFetcher {
func fetchImages ( completion : @escaping ( Result < [ UIImage ] , Error > ) -> Void ) {
// .. perform data request
}
}

Convert Function to Async

The first refactoring option converts the fetchImages method to an asynchronous variant without keeping the non-async variant. This option is useful if you don't want to keep the original implementation. The resulting code is as follows:

 struct ImageFetcher {
func fetchImages ( ) async throws -> [ UIImage ] {
// .. perform data request
}
}

Add Async Alternative

Adding the async-alternative refactoring option ensures that the old implementation is kept, but adds an available property:

 struct ImageFetcher {
@available ( * , renamed : "fetchImages()" )
func fetchImages ( completion : @escaping ( Result < [ UIImage ] , Error > ) -> Void ) {
Task {
do {
let result = try await fetchImages ( )
completion ( .success ( result ) )
} catch {
completion ( .failure ( error ) )
}
}
}


func fetchImages ( ) async throws -> [ UIImage ] {
// .. perform data request
}
}

The available property is very useful to understand where you need to update your code to accommodate the new concurrency variables. Although, the default implementation provided by Xcode does not have any warnings because it is not marked as deprecated. To do this, you need to adjust the available flags as follows:

 @available ( * , deprecated , renamed : "fetchImages()" )

The benefit of using this refactoring option is that it allows you to gradually adapt to the new structure concurrent changes without having to convert your entire project at once. It is valuable to do a build in between so that you know your code changes are working as expected. Implementations that leverage the old approach will get a warning like the following.

You can change your implementation incrementally throughout your project and use the Fix buttons provided in Xcode to automatically convert your code to take advantage of the new implementation.

Add Async Wrapper

The final refactoring approach will be the simplest transformation to use, as it will simply leverage your existing code:

 struct ImageFetcher {
@available ( * , renamed : "fetchImages()" )
func fetchImages ( completion : @escaping ( Result < [ UIImage ] , Error > ) -> Void ) {
// .. perform data request
}

func fetchImages ( ) async throws -> [ UIImage ] {
return try await withCheckedThrowingContinuation { continuation in
fetchImages ( ) { result in
continuation .resume ( with : result )
}
}
}
}

The newly added methods take advantage of the withCheckedThrowingContinuation method introduced in Swift, which allows for effortless conversion of closure-based methods. Non-throwing methods can use withCheckedContinuation, which works the same way but does not support throwing errors.

These two methods will suspend the current task until the given closure is called to trigger the continuation of the async-await method. In other words: you must make sure that the continuation​ closure is called upon the callback of your own closure-based method. In our case, this boils down to calling the continuation with the result value we returned from the initial fetchImages callback.

Choosing the right async-await refactoring approach for your project

These three refactoring options should be enough to convert your existing code to asynchronous alternatives. Depending on the size of your project and how much time you have to refactor, you may want to choose a different refactoring option. However, I strongly recommend applying changes gradually, as it allows you to isolate the parts that you change, making it easier for you to test whether your changes work as expected.

Resolving Errors

Resolve "Reference to captured parameter 'self' in concurrently-executing code" error

Another common mistake when using asynchronous methods is this:

"Reference to captured parameter 'self' in concurrently-executing code"

This roughly means that we are trying to reference an immutable instance of self. In other words, you may be referencing a property or an immutable instance, for example, a struct like in the following example:

Modifying immutable properties or instances from asynchronously executing code is not supported.

This error can be fixed by making the property mutable or by changing the struct to a reference type such as a class.

End of enumeration

Will async-await be the end of the Result enumeration?

We have seen that async methods have replaced asynchronous methods that utilize closure callbacks. We can ask ourselves if this will be the end of the Result enum in Swift [2]. Ultimately we will find that we really don’t need them anymore, as we can use try-catch statements combined with async-await.

The Result enum isn't going away anytime soon, as it's still used in many places throughout Swift projects. However, I wouldn't be surprised to see it deprecated once async-await adoption grows. Personally, I don't use the Result enum anywhere else besides completion callbacks. Once I fully embrace async-await, I won't be using this enum anymore.

in conclusion

async-await in Swift allows structured concurrency, which will improve the readability of complex asynchronous code. Closures are no longer required, and the readability of calling multiple asynchronous methods after each other is greatly improved. Some new types of errors may occur, and these errors can be solved by ensuring that asynchronous methods are called from functions that support concurrency and do not change any immutable references.

References

[1]Swift Concurrency Manifesto by Chris Lattner: https://gist.github.com/lattner/31ed37682ef1576b16bca1432ea9f782

[2]Result enumeration: https://www.avanderlee.com/swift/result-enum-type/

<<:  Customizing a Line Chart with SwiftUI Charts in iOS 16

>>:  iOS 16.2 finally supports 120Hz high refresh rate!

Recommend

Four major marketing tactics for Children’s Day!

Non-high-energy warning: Another Children's D...

After 20 hours, Meituan is back online! The reason for the removal is revealed!

I believe that all of my friends in the mobile ci...

Why does the last 1% of battery in a mobile phone last so long?

How would you feel when your phone only has 1% ba...

Why do beverage bottles have five-petal flowers on the bottom?

Review expert: Meng Meng, associate researcher at...

Brand Yuanqi Forest Marketing Data Report

When it comes to sugar-free beverages with “0 sug...

5K iMac reveals Apple's cutting-edge technology

As the first all-in-one computer with a 5K displa...

6 ways to monetize short video traffic!

Many people say that short videos cannot generate...

JD.com’s fresh food category operation case analysis: How to build a store?

Sometimes, a store clearly has significant advant...

Why is China's Sky Eye built in Guizhou?

Mixed Knowledge Specially designed to cure confus...

These 3 information flow cases increased my monthly salary to 30,000!

Next, let’s look at some interesting cases. Case ...