Running tasks concurrently with Swift Concurrency’s async let

Published on: August 9, 2021

In last week's post, I demonstrated how you can use a task group in Swift to concurrently run multiple tasks that produce the same output. This is useful when you're loading a bunch of images, or in any other case where you have a potentially undefined number of tasks to run, as long as you (somehow) make sure that every task in your group produces the same output.

Unfortunately, this isn't always a reasonable thing to do.

For example, you might already know that you only have a very limited, predetermined, number of tasks that you want to run. These tasks might not even produce the same output which could make matters more complicated.

In these scenarios, it makes sense to use Swift's async let syntax instead of a task group.

If you're not yet familiar with task groups, make sure you take a look at the following posts if you want to understand the complete picture of what we're covering in this post.

In this post, you will learn when it makes sense to use async let, how it's used, and you'll learn how async let fits into the bigger picture of an application that uses Swift Concurrency.

Knowing when to use async let

In my post on using a task group for multiple tasks with varying output, you saw how you could wrap the output from a task in a task group in a TaskResult enum that we defined ourselves. While this is convenient when we don't know how many tasks we might have to run exactly in a task group, you can imagine that this isn't always desirable.

For this exact reason, the Swift core team gave us a convenient tool to concurrently run a predetermined number of tasks and awaiting their results only when we actually need them.

This allows you to perform work as soon as possible, but not await it if you don't need it right away.

Let's look at an example.

Imagine that you're implementing a bootstrapping sequence for a movies app. When this sequence is kicked off, you want to do a bunch of stuff. For example:

  • Fetch movies from a server
  • Asynchronously fetch the current user
  • Load user's favorites
  • Load user's profile
  • Load user's movie tickets

Without async let, and without task groups, you might write something like this:

func bootstrapSequence() async throws {
    let movies = await loadMovies() // will cache movies as well
    if let user = await currentUser() {
        let favorites = try await updateFavorites(user: user)
        let profiles = await updateUserProfile(user: user)
        let tickets = await updateUserTickets(user: user)
    }

    // use properties or ignore their output as needed
}

This code will work fine, but there's a bit of an optimization problem here. The steps in our sequence are run serially rather than concurrently.

Notice that the movies and user tasks can run concurrently. They don't depend on each other in any way.

The other three tasks depend on both movies and user. Or rather, they depend on user but it would be nice if movies are loaded too.

I don't want to spend too much time on the details of what each of these functions do, but the rest of this post makes a lot more sense if I explain my intention behind them at least a little bit.

  • loadMovies() -> [Movie] will load movies a list of movies from a remote source and cache them locally.
  • currentUser() -> User will check if a user exists locally or attempts to fetch the user from the server. User object is a bare-bones container of user info.
  • updateFavorites(user:) -> [Movie] loads a list of movie ids that the user marked as favorite from the server, and associates them with a Movie object. If the Movie is not cached it will be fetched from a server.
  • updateUserProfile(user:) -> UserProfile fetches and caches the user's profile information from a server (contains a lot more info than the object returned by currentUser)
  • updateUserTickets(user:) -> [Ticket] updates the user's movie tickets in the local store. Tickets are associated with Movie objects from the local cache. If a specific movie doesn't exist locally it's fetched from the server.

As you can see, each of these steps in the sequence does a bunch of stuff and we want to do as many of these things concurrently as possible.

This means that we can divide the sequence into two steps, or sections:

  • Load movies and current user object
  • Update favorites, profile, and tickets

With a task group this would be rather tedious because every task has a different result, and we'd need to split our group in two somehow, or we'd need to use two task groups. Not ideal, especially because we're not dealing with an indeterminate number of tasks.

Let's see how async let helps us solve this problem.

Using async let in your code

As I mentioned earlier, async let allows us to run tasks concurrenly without suspending the calling context to await the task's output so we can only await their results when we need them.

The simplest usage of an async let is when you want to run a task as soon as a function starts, but you don't want to await it immediately. Let's take a look at an example of this before we go back to the more complex scenario I explained in the previous section.

Imagine that you're writing a function where you want to load some information from the network to update a local record, and while that happens you want to see if a local record exists so you know whether you'll need to create a new record. The network code will run asynchronously using async let so we can fetch the most up to date information from the server while checking our local store at the same time:

func fetchUserProfile(user: User) async -> UserProfile {
    // start fetching profile from server immediately
    async let remoteProfile = network.fetchUserProfile(user: user)

    // fetch (or create) and await local profile
    let localProfile = await localStore.fetchUserProfile(user: user)

    // update local profile with remote profile
    await localProfile.update(with: remoteProfile)
    await localStore.persist(localProfile)

    return localProfile
}

In this code, the network call is executed immediately and it will start running right away.

While this network call is executing, we'll attempt to load a user profile from the local cache which could take a little while too depending on what we're using to store the profile. The exact details of this aren't relevant for now.

Once we've obtained a local profile, we call await localProfile.update(with: remoteProfile). At this point, we want to wait for the profile that we loaded from the network and use it to update and persist the local version.

The network call might have already completed by the time we use await to wait for its result, but it could also still be in-flight. The nice part is that the network call runs concurrent with the rest of fetchUserProfile and we don't suspend fetchUserProfile until we don't have any other choice. In other words, we were able to do two things concurrently in fetchUserProfile (perform network call, and find the cached user profile) by using async let.

When you think about the flow I showed you in the previous section for example, we'll want to run loadMovies() and currentUser() concurrently, await their results, and then proceed with the next steps in our bootstrapping sequence.

Here's what this first part of the sequence would look like:

func bootstrapSequence() async throws {
    async let moviesTask = loadMovies()
    async let userTask = currentUser()

    let (_, currentUser) = await (moviesTask, userTask)

    // we'll implement the rest of the sequence soon
}

In this code, I create two tasks with the async let syntax. This essentially tells Swift to start running the function call that follows it immediately, without awaiting their results. You can have multiple of these async let tasks running at the same time simply by defining them one after the other like I did in the code snippet above.

Earlier, you saw that I needed to await remoteProfile to get the result of my async let remoteProfile. In this case, I want to await two tasks. I want to ignore the output of loadMovies() while assigning the output of currentUser() to a property that I can use later.

As you saw earlier, it's possible to use the output of an async let task inline by writing await before the expression that uses the task's output. For example, I could write the following to use the output of currentUser() without assigning the output to an intermediate property:

async let user = currentUser()
let tickets = await updateUserTickets(user: user)

The code above would await the value of user (which would be the output of currentUser()), and then run and await updateUserTickets(user:). This is very similar to how try works in Swift where you only need to write a single try to apply it to an entire expression even if it contains multiple throwing statements. For clarity, I'm going to keep using the approach you saw earlier where I explicitly awaited the result of a property called userTask.

Once the user and movies are loaded, we can concurrently run the second part of the sequence:

func bootstrapSequence() async throws {
    async let moviesTask = loadMovies()
    async let userTask = currentUser()

    let (_, currentUser) = await (moviesTask, userTask)

    guard let user = currentUser else {
        return
    }

    async let favoritesTask = updateFavorites(user: user)
    async let profilesTask = updateUserProfile(user: user)
    async let ticketsTask = updateUserTickets(user: user)

    let (favorites, profile, tickets) = try await (favoritesTask, profileTask, ticketsTask)

    // use the loaded data as needed
}

Notice how this follows the exact same pattern that you saw before. I define some aysnc let properties to create a bunch of tasks that will run concurrently as soon as they are created, and I use await to wait for their results.

Note that this time around, I had to write try await. That's because updateFavorites can throw. I applied the try to the entire expression because I think it reads a bit nicer and it makes it easier to change other tasks to be throwing later. It would have been equally valid for me to write the following:

let (favorites, profile, tickets) = await (try favoritesTask, profileTask, ticketsTask)

Which, in my opinion, just doesn't read as nicely as the version I showed you earlier.

In terms of usage, there really isn't much else to async let. You defined tasks with async let, they begin doing their work as soon as they are created, and you must use await whenever you want to use the async let task's value.

I love how easy to use this API is, and how it allows us to build relatively complex sequences and flows without a ton of effort. As an exercise, take a look at this post I wrote on running some tasks concurrently waiting for all tasks to be completed using DispatchGroup. You'll see that it's not nearly as nice and convenient as Swift Concurreny's async let.

While async let is easy to use, there's a lot going on behind the scenes to make it work. And there are some important rules you should understand. Let's explore those next.

Understanding how async let works

When you create an async let, you spawn a new Swift Concurrency task behind the scenes. This task will run as a child of the task that's currently running (ever async scope in Swift Concurrency is part of a task). This new task will inherit things like task local values, and it will run on the same actor as the actor that you spawned the task from.

It's important to understand this because it will help you reason about what's happening behind the scenes when you use async let since it's subtly different from calling an async function and using await to wait for the function's result.

When you normally await an async function's output, this is all done as part of the same task. Since async let will run concurrently with the function that you used it in, it'll be run in a new task. This means that, similar to how you spawn tasks in a task group, you spawn a new task every time you write async let.

With this in mind, it's important to think about what might happen when you spawn a task with async let without ever awaiting its result. For example:

func bootstrapSequence() async throws {
    async let moviesTask = loadMovies()
    async let userTask = currentUser()

    let (_, currentUser) = await (moviesTask, userTask)

    guard let user = currentUser else {
        return
    }

    async let favoritesTask = updateFavorites(user: user)
    async let profilesTask = updateUserProfile(user: user)
    async let ticketsTask = updateUserTickets(user: user)

    // we don't await any of the async let's above
}

Since we don't await the results of our async let tasks, the bootstrapSequence function will exit after the last async let task is started. When this happens, our tasks will go out of scope, and they get marked as cancelled which means that we should stop performing any work as soon as we can to respect Swift Concurrency's cooperative cancellation paradigm.

In other words, you should not use an async let as a means to run code asynchronously after your function has exitted its scope.

The last thing I want to cover in this post is the restriction of applying async only to let properties.

You can't write async var to have an asynchronous variable. The reason is that your created property will be bound to a task, and its value doesn't become available until it's awaited and the task produces a result. If you would be able to write async var this feature would become increasingly complex because of how the binding from task to property works.

In Summary

In this post you learned a lot about Swift Concurrency's async let. You learned that async let is a feature that helps you run unrelated asynchronous function calls concurrently as their own tasks. You learned that async let solves a propblem that's similar to the problem that's solved by task groups except it doesn't have the limitation of only being applicable for tasks that produce the same output.

I showed you how you can use async let to build a complex loading sequence that can run in two steps. The first step performs two concurrenct tasks, and the second step runs three concurrent tasks that depend on the first two tasks. You saw that this was fairly trivial to implement with async let and awaiting results where needed.

Lastly you gained some deeper insights into how async let works. You learned that an async let creates a child task of your current task under the hood, and you learned that this task is cancelled whwnever the function it's created in goes out of scope. To avoid this, you should always await the results of your async let tasks.

Overall I think async let is an incredibly useful feature for scenarios where you want to run several tasks concurrently before doing something else. A bootstrapping process like you saw in this post is a good example of that.

Subscribe to my newsletter