developers

Async Await in Swift

A guide to fully understand the new concurrency system in Swift.

Aug 12, 20218 min read

This year, WWDC came with a bunch of new features and updates. Maybe one of the most expected was the introduction of the new concurrency system by using async/await syntax. This is a huge improvement in the way that we write asynchronous code.

Before Async/Await

Imagine that we are working on an app for a grocery store and, we want to display its list of products. We are probably going to have something like this:

func fetchProducts(_ completion: @escaping([Product]) -> Void) {...}

var products = [Product]()
fetchProducts { [weak self] products in
    guard let strongSelf = self else { return }
    strongSelf.products.append(contentsOf: products)
}

A pretty standard and well-known code using completion blocks. Now suppose that the grocery store has, once in a while, some kind of offers for some products (e.g., "Take 2, pay 1"). And, we want to hold a list with these offers. Let's adjust our code by creating a new function to retrieve a String with the promotion text, given a specific product.

func fetchProducts(_ completion: @escaping([Product]) -> Void) {...}
func getOffer(for product: Int, @escaping(String) -> Void) {...}

typealias ProductOffer = (productId: Int, offer: String)
var products = [Product]()
var offers = [ProductOffer]()

fetchProducts { [weak self] products in
    guard let strongSelf = self else { return }

    for product in products {
        strongSelf.products.append(product)

        getOffer(for: product.id) { [weak self] offerText in
            guard let strongSelf = self else { return }
            let productOffer = ProductOffer(productId: product.id, offer: offerText)
            strongSelf.offers.append(productOffer)
        }
    }
}

We only have two nested closures for a simple feature, and you can see that our code starts to get a little messed up.

Async/Await

From Swift 5.5 onwards, we can start using async/await functions to write asynchronous code without using completion handlers to returns values. Instead, we are allowed to return the values in the return object directly.

To mark a function as asynchronous, we only have to put the keyword async before the return type.

func fetchProducts() async -> [Product] {...}
func getOffer(for product: Int) async -> String {...}

This is much easier and simple to read, but the best part comes from the caller's side. When we want to use the result of a function marked as async, we need to make sure that its execution is already completed. To make this possible, we need to write the await keyword in front of the function call. By doing this, the current execution will be paused until the result is available for its use.

let products = await fetchProducts()

for product in products {
    let offerText = await getOffer(for: product.id)

    if !offerText.isEmpty {
        let productOffer = ProductOffer(productId: product.id, offer: offerText)
        offers.append(productOffer)
    }
}

Although, if we want to execute other tasks while the async function is being executed, we should put the keyword async in front of the variable (or let) declaration. In this case, the await keyword will need to be placed in front of the variable (or let) where we are accessing the result of the async function.

async let products = fetchProducts()
...
// Do some work
...
print(await products)

Parallel Asynchronous Functions

Now imagine that in our app, we want to fetch products by category—for example, just the frozen products. Let's go ahead and make the adjustments to our code.

enum ProductCategory {
    case frozen
    case meat
    case vegetables
    ...
}

func fetchProducts(fromCategory category: ProductCategory) async -> [Product] {...}

let frozenProducts = await fetchProducts(fromCategory: .frozen)
let meatProducts = await fetchProducts(fromCategory: .meat)
let vegetablesProducts = await fetchProducts(fromCategory: .vegetals)

This is ok, but the code will run in serial mode, which means that we won't start fetching the meat products until the frozen products are retrieved. Same for the vegetables. Remember, we write the await keyword if we want to pause our execution until the function completes its work. However, in this particular scenario, we could start fetching the three categories at the same time, running in parallel.

In order to accomplish this, we need to write the async keyword in front of the var (or let) declaration and use the await keyword when we want to use it.

async let frozenProducts = await fetchProducts(fromCategory: .frozen)
async let meatProducts = await fetchProducts(fromCategory: .meat)
async let vegetablesProducts = await fetchProducts(fromCategory: .vegetables) 

....

let products = await [frozenProducts, meatProducts, vegetablesProducts]

Error handlers

Our fetching functions might have some errors that make it impossible to return the expected data values. How do we handle this in our async/await context?

We have a couple of options. The first one is to return the well-known Result object.

func fetchProducts() async -> Result<[Product], Error> {...}

let result = try await fetchProducts()
switch result {
    case .success(let products):
        // Handle success
    case .failure(let error):
        // Handle error
}

Another one is to use the try/catch approach.

func fetchProducts() async throws -> [Product[ {...}
...
do {
    let products = try await fetchProducts()
} catch {
    // Handle the error
}

The main benefit that we had when using the Result type was to improve our completion handlers. In addition to that, we got a cleaner code at the moment we used the result, being able to switch between success and failure cases.

On the other hand, the use of throw errors adds extra readability in the function's definition because we only need to put the result type that the function will return. The errors handling is hidden in the function's implementation.

Asynchronous Sequences

Let's say that we have a requirement to load a list of products from some .csv file. A traditional way to do this is to load all the lines at once and then start processing them. But, what happens if we want to start doing some work as soon as we have one of the lines available? We can now do this using an asynchronous sequence.

let url = URL(string: "http://www.grocery.com/products.csv")
for try await in url.lines {
    // Do some work
}

Using this new feature also allows us to handle this particular case (reading a file) in a simpler way than before. You can check this stackoverflow discussion to see how we had to do this and see the advantages that this approach has over the previous one.

Async/Await vs. Completion Handlers

As we saw in the previous sections, the use of async/await syntax comes with a lot of improvements in contrast with using completion blocks. Let's make a quick recap.

Advantages

  • Avoid the Pyramid of Doom problem with nested closures
  • Reduction of code
  • Easier to read
  • Safety. With async/await, a result is guaranteed, while completion blocks might or might not be called.

Disadvantages

  • It's only available from Swift 5.5 and iOS 15 onwards.

Actors

Take a look at the following example, just a simple Order class in which we will be adding products and eventually make the checkout.

class Order {
    
    var products = [Product]()
    var finalPrice = 0

    func addProduct(_ product: Product) {
        products.append(product)
        finalPrice += product.price
    }
}

If we are in a single-thread application, this code is just fine. But what happens if we have multiple threads that can access our order's final price?

  1. We are on the product list and add some specific products to our order. The app will call the addProduct function.
  2. The product is added to our order's product list
  3. Before the final price gets updated, the user tries to checkout.
  4. The app will read the final price of our order
  5. The addProduct function completes and updates the final price. But the user already checkout and paid less than they should.

This problem is known as Data Races when some particular resource could be accessed from multiple parts of the app's code.

Actors, also introduced in Swift 5.5 and iOS 15, resolve this problem for us. An Actor is basically like a class but with a few key differences that make them thread-safe:

  • Only allow accessing their state by one task at a time
  • Stored properties and functions can only be access from outside the Actor if the operation is performed asynchronously.
  • Stored properties can't be written from outside the Actor.

On the downside:

  • Actors do not support inheritance

You can think about the Actors like a similar solution of the semaphores) concept.

To create one, we just need to use the actor keyword.

actor Order {
    
    var products = [Product]()
    var finalPrice = 0

    func addProduct(_ product: Product) {
        products.append(product)
        finalPrice += product.price
    }
}

And we can create an instance using the same initializer syntax as structures and classes. If we want to access the final price, we must do it using the keyword await (because outside the actor's scope, we are only allowed to access the properties asynchronously).

print(await order.finalPrice)

Conclusion

Definitely async/await brings to the table an easier way to write asynchronous code, removing the need to use completion blocks. In addition, we get more readable and flexible code if our application starts scaling up.

However, the minimum iOS deployment target will be an entry barrier for most of us unless you start a project from scratch, in which case is highly recommended to wait until the official release of iOS 15 + Xcode 13 + Swift 5.5 to take full advantage of the new concurrency system.