Why You Should Adopt Swift Async/Await Right Now

Russell Mirabelli
Atlas

--

Apple has just made Async/Await available for all developers whose projects target iOS 13 and up (using Xcode 13.2 or later). This can change the way you write code, understand software contracts, and commit to unit testing. For a couple of significant reasons, you should start moving to this mechanism as soon as you can.

Nearly all modern applications make some use of concurrency. Whether we are keeping our UIs responsive while downloading data for the user, or performing complex machine learning in the background, we often take advantage of multiple threads and build our software to run multiple tasks at the same time.

For this discussion I’ll be talking about high-level concurrency and not about directly using primitives such as spawning threads, mutexes, or even using Grand Central Dispatch. These implementations occur at a lower level, and I’m focused on the higher-level abstractions in my day-to-day responsibilities.

Methods of implementing concurrency

In the Swift programming language, there have been three previous models we’ve used to handle this concurrency. They have each served us well, and need not disappear overnight. However, in many cases they can and should be replaced with Async/Await implementations.

Delegate-based concurrency

With Delegate-based concurrency, we establish a protocol with methods that will be called when the asynchronous task is complete. Then, when making the asynchronous call, the delegate’s functions are called after completion.

Potential problems would include the delegate being changed while the asynchronous call is in progress, the delegate going out of scope or otherwise being released, or the delegate being retained in a manner that causes a retain cycle. Conceptually, the place where the handling of asynchronous results is often far removed from the place where the call is made — this increases the mental load upon the developer.

While delegates are still widely used for many purposes, they are not generally in favor as an implementation method for asynchronous calls.

In the above example, a delegate is provided for an asynchronous data fetching task. Note how the resolution of received data is located far from the place in code where the call is made to fetch the data.

Closure-based concurrency with Result

A more typical usage pattern for asynchronous calls is to provide a closure which will be called when the asynchronous task is complete. This has the benefit of moving our reasoning about a problem closer to the point where the call to the asynchronous task is made.

Before the Result<T, Error> type was standardized, multiple completions would be provided (one for error states, one for success) or other ugly mechanisms were required to handle both conditions. Thankfully, Result became a standard part of Swift, and we no longer have to come up with our own mechanism for this.

A problem with this method is that the asynchronous method being called has to be trusted to call the closure exactly and only once. This is a significant assumption, and one which can lead to subtle bugs — especially as asynchronous calls are chained together with rightward drift.

In the above sample, note the extreme rightward drift. Also, because we didn’t employ [weak self] in our closures, there’s a chance that we will create a retain cycle!

Publisher-based concurrency

Two years ago, Apple released a publisher-based concurrency model, Combine. Combine remains excellent for data streams. Also, the pub/sub model is seen widely in frameworks on other platforms.

An example of Pub/Sub using Combine, from Apple’s documentation: https://developer.apple.com/documentation/combine/receiving-and-handling-events-with-combine

Async/Await based concurrency

Finally, the part of the article you came here for.

Async/Await in Swift allows for some significant benefits over the above methods. The most obvious benefit is code locality and readability. The code that uses the data from an asynchronous process is located right next to the code that creates the data from an asynchronous process.

In other words, the code can be read in a straight line. There’s no need to hunt for where the closure might be, or read rightward-drifting code. Each call is simply placed one element after another.

This article is not meant to be a tutorial on async/await, and so this sample will be small. If you’re interested in a more full-featured article on how to implement async/await, or even how to upgrade your current codebase to use async/await, let me know!

A sample showing the simplicity of async/await vs completion-based asynchronous code.

The benefits of Async/Await in Swift

The biggest benefit you’ll gain from using async/await is that your code can no longer hide some of the common errors in closure-based asynchronous code; You cannot accidentally call a closure more than once or less than once! I’m a big fan of moving as much risk to compile-time as possible. By using async and throw, we guarantee that we exit out of the asynchronous function one time and one time only.

Your code will additionally become much more easily testable. Writing unit tests in Xcode for asynchronous functions is challenging; it requires setting up additional scaffolding, and then the developer must heuristically decide how long asynchronous process “should” take. Instead, your tests can be considerably simpler and easier to read.

Unit testing using async/await is significantly simpler than using closures. The easier it is to write a unit test, the more likely you are to do so!

With the benefits of improved readability & locality, compile-time checking, and better testability, there’s no better reason needed to get started bringing your asynchronous code into an Async/Await model.

--

--