A Whirlwind Tour of Swift Concurrency

So, I started writing a guide on how to implement an authorization layer in our apps using Firebase Authentication and before I knew it, I had written well over a thousand words on just the basics of Swift Concurrency. It occurred to me that it might be worthwhile spending some time taking a whirlwind tour of Swift Concurrency so that it might set us up for success as we all begin to adopt Swift 6!

If you were to take a couple of takeaways of what Swift Concurrency offers us, they are:

  1. Improved code readability and more maintainable code
  2. Much safer and more efficient multithreading
  3. Easier ways to manage tasks and cancellation

There are a bunch more benefits that we could go over but this is a whirlwind tour after all! Before jumping in to each of these, it's worth getting quick understanding of what Swift Concurrency is.


Swift Concurrency in a nutshell

Let’s take a step back and look at what concurrency means in general. Imagine being at a conference: a keynote speech might be going on in one room, while a hands-on workshop runs simultaneously in another. These events are “concurrent”—happening at the same time but independently of each other.

Now, bring that idea into Swift, and we’re talking about running multiple things side-by-side in our app. Swift Concurrency gives us the tools to manage this easily and safely. In one of my apps, for example, when a user taps "Save" on their profile, two separate tasks kick off: their profile picture is sent to Firebase Storage, and their profile info is saved to Firebase Firestore. Thanks to concurrency, both uploads can happen at the same time. When the image upload completes, the app automatically updates the Firestore document with the image URL—all without making users wait for one upload to finish before the next one starts.

Swift Concurrency makes this “multi-tasking” easy to manage, so your app can handle multiple things at once in a way that feels seamless and efficient.


Improved code readability and more maintainable code

Swift Concurrency guides us to write more readable and more maintainable code. This is always a good thing, right? We're provided two keywords to achieve this: async and await. Before Swift Concurrency, we relied heavily on callback handlers. I don't know about you, but if we're not being careful, we'd end up in a right mess. Heck, I've had to rewrite many a function in order to understand the order of callbacks.

Let's look at some code to better understand why:

fetchData { result in
    switch result {
    case .success(let data):
        processData(data)
    case .failure(let data):
      print("Error: \(error)
    }
  }

Here we have some asynchronous code fetching some data. Depending on the value of result, we're either passing the data along to another function process(data) or we're presenting the user with an error. I mean, this example isn't that unreadable but the alternative is much more concise.

Here's how we might rewrite this function with Swift Concurrency in mind:

let data = try await fetchData()
processData(data)

I find this to be far more readable and easier to reason with that the previous example. We create a constant called data, have Swift try and execute fetchData() as well as telling Swift that we await the results of fetchData(). While both examples are doing the exact same thing, Swift Concurrency improves readability while also reducing complexity.


Much safer and more efficient multithreading

The nature of running multiple tasks at the same time can potentially lead to problems. Just recently, I had to debug some code that had introduced race conditions as well as inconsistent state, which I might explore in a future post. Swift Concurrency efficiently takes care of these problems by using what are called actors; a reference type that isolate state and maintain thread-safe operations all the while removing the any requirement for manual synchronization such as DispatchQueue. In short, actors protect mutable state across threads by making sure that only one task can access mutable data at any given time. By doing this, actors help avoid the likes of data races, meaning that we can safely update any given shared state.

Actors

Let's look at an example of an actor:

actor Counter {
    var value = 0

    func increment() {
        value += 1
    }
}

Here we have an actor called Counter, which encapsulates a single property, value, and a function, increment(). Let's break things down, line-by-line.

We define a new actor type named Counter. An actor in Swift is a reference type that provides data isolation. It ensures that only one task at a time can modify its mutable state. Actors manage access to their properties and methods in a way that prevents dreaded data races (conflicts from concurrent access) by allowing only one piece of code to interact with their mutable state any given time. No more need for manual locking mechanisms such as DispatchQueue or NSLock!

Next, we create a var called value and assign it the value of 0. value is a mutable property. We can read and write it. Since value is within Counter—an actor—only a single task at any point at any time can access or modify it, and only from within the actor itself. Swift guarantees that value is protected from concurrent access by other parts of our app.

Finally, we have increment()—a function that increases value by 1 in a way that is safe from concurrency issues, which would be difficult in a non-actor environment. Like all actor functions, increment() is isolated. This means that only one task can run this function at any given time on any given instance of Counter, ensuring thread safety. In a traditional, multi-threaded environment, multiple threads trying to increment value simultaneously could cause inconsistencies. For example, if multiple threads read the same initial value and attempt to increment it, one or more of these updates could get lost. Swift's actor model ensures that this operation is atomic—it completes fully without interruption. Now in our actor environment, Swift automatically queues multiple tasks attempting to call increment() concurrently so that only one task increments value at a time.

Our actor in action

When we create an instance of Counter and call increment, we'll use the await keyword:

let counter = Counter()

Task {
    await counter.increment()
    print(await counter.value) // Expected output: 1
}

Why should we use await? Well, increment() might need to wait its turn if other tasks are accessing counter. Using await indicates to Swift (and other developers) that the call might involve a delay.

Key concepts

  • Automatic Data Isolation: The Counter actor ensures safe, sequential access to value, even if multiple tasks interact with it.
  • Removing Data Races: We don't have to worry about race conditions or inconsistent state when calling increment().
  • Ease of Use: Swift Concurrency lets us work with shared, mutable state—such as value—in a way that's both easy and safe without needing explicit locking or synchronization.

Easier ways to manage tasks and cancellation

We touched upon this in the previous takeaway: Swift Concurrency simplifies task management, including cancellation. We can easily cancel tasks using Task, and with async and await, we have built-in support for handling cancellations without needing any extra plumbing.

Let's look at an example:

func loadImage() async throws -> UIImage {
    guard !Task.isCancelled else { throw CancellationError() }
    // Fetch image
}

As always, let's go over this line-by-line.

We define a function called loadImage(), mark it with async and throws, and it returns a UIImage. By marking this function with async, we're saying that it is an asynchronous function, so it can perform work that might involve waiting, like a network request. When calling loadImage(), we use await to indicate that we're waiting for the work of loadImage() to complete. We also mark loadImage() with throws, which indicates that the function can throw an error if it encounters a problem, such as a failed network request or, in our example, a cancellation.

Next up is guard !Task.isCancelled else { throw CancellationError() }. This statement is a guard condition that checks whether the current task has been cancelled. Task.isCancelled is a property that returns true if the current task—the one running loadImage()—has been marked for cancellation. In our case, if the task hasn't been cancelled, then throw a CancellationError by executing the function immediately. This way, we avoid doing unnecessary work, such as fetching an image that's no longer needed.

Why should we check for Task.isCancelled?

When tasks are cancelled using the Swift Concurrency model, they aren't being terminated by force. Instead, they're marked for cancellation and will only stop running if they cooperate by checking for the cancellation state using Task.isCancelled. This is what we call, cooperative cancellation. The task itself can decide when to stop, allowing it to clean up resources and exit gracefully.

Calling Task.isCancelled at key points within the function, we allow it to stop running if it's cancelled without going through any unnecessary steps.

Cancellation in action

Let's take a look at how we might use loadImage() with task cancellation in mind:

let imageLoadTask = Task {
    do {
        let image = try await loadImage()
        print("Image loaded:", image)
    } catch is CancellationError {
        print("Image load cancelled.")
    } catch {
        print("An error occured:", error)
    }
}

imageLoadTask.cancel()

In this example, we start the task by creating imageLoadTask to run loadImage(). It begins working asynchronously. If imageLoadTask.cancel() is called later, a flag is set that marks the task as cancelled. Inside loadImage(), we use guard !Task.isCancelled to check for cancellation before doing anything else. If the task is cancelled, the function immediately throws a CancellationError, and the task no longer proceeds to fetch the image.

Why use CancellationError?

Using CancellationError provides us a standardized way to handle cancellations in Swift Concurrency. CancellationError specifically indicates that the task didn't fail due to a problem like a network error—it stopped because it wasn't needed anymore. This allows any code handling the result of the task (like calling a function) to differentiate between a real error and a user-initiated cancellation.

Adding more cancellation checks

We might have tasks that have multiple stages. In such cases, we can include more Task.isCancelled checks throughout the function in question to allow it to exit at different stages. This helps avoid any unnecessary work, especially in tasks with long-running steps.

Let's expand our loadImage() function with multiple checks:

func loadImage() async throws -> UIImage {
    guard !Task.isCancelled else { throw CancellationError() }

    let imageData = try await fetchData()
    guard !Task.isCancelled else { throw CancellationError() }

    let image = UIImage(data: imageData) ?? UIImage()
    guard !Task.isCancelled else { throw CancellationError() }

    return image
}

Each major step includes a Task.isCancelled check to make sure that we stop as soon as possible if the task is cancelled.

What are the benefits of Swift's cooperative cancellation model?

  1. Efficient resource usage: Cancellation prevents unnecessary work from continuing, saving CPU and memory.
  2. Controlled exit: Unlike forced cancellation, cooperative cancellation allows functions to safely clean up resources and leave the task in a predictable state.
  3. Improved UX: When tasks are cancelled promptly, users see faster responses when they change their minds or move to another part of the app.
  4. Error differentiation: Using CancellationError, we have a way to differentiate between cancellations and other errors, helping us to handle each case appropriately.

Let's summarize!

Swift Concurrency empowers us with async/await allowing for more readable code, actors for safe multithreading, and ways to deal with tasks and cancellations effectively. With these, we can write code that's not only cleaner and more maintainable, but also avoids common multithreading pitfalls.

Hopefully, this post has shed some light on to some key benefits of Swift Concurrency and why we ought to adopt this model as we build insanely great apps.


How did I do? Did I miss anything, or could I have explained something better? I’d love to hear your thoughts—reach out to me on X at @stphndxn!

Stephen Dixon

Stephen Dixon

iOS Developer. Previously at strong.app and buffer.com. Founder ios-developers.io. Building and designing for screens since 1998!
Manchester, England