How async/await works internally in Swift

How async/await works internally in Swift

async/await in Swift was introduced with iOS 15, and I would guess that at this point you probably already know how to use it. But have you ever wondered how async/await works internally? Or maybe why it looks and behaves the way it does, or even why was it even introduced in the first place?

In typical SwiftRocks fashion, we're going deep into the Swift compiler to answer these and other questions about how async/await works internally in Swift. This is not a tutorial on how to use async/await; we're going to take a deep dive into the feature's history and implementation so that we can understand how it works, why it works, what you can achieve with it, and most importantly, what are the gotchas that you must be aware of when working with it.

Disclaimer: I never worked at Apple and have nothing to do with the development of async/await. This is a result of my own research and reverse-engineering, so expect some of the information presented here to not be 100% accurate.

Swift, and the goal of memory safety

Swift's async/await brought to the language a brand new way of working with asynchronous code. But before trying to understand how it works, we must take a step back to understand why Swift introduced it in the first place.

The concept of undefined behavior in programming languages is something that surely haunts anyone who ever needed to work with the so-called “precursor programming languages” like C++ or Obj-C.

Programming languages historically provided you with 100% freedom. There were no real guardrails in place to prevent you from making horrible mistakes; you could do anything you wanted, and the compilers would always assume that you knew what you were doing.

On one hand, this behavior on behalf of the languages made them extremely powerful, but on the other hand, it essentially made any piece of software written with them a minefield. Consider the following C code where we attempt to read an array's index that doesn't exist:

// arr only has two elements
void readArray(int arr[]) {
    int i = arr[20];
    printf("%i", i);
}

We know that in Swift this will trigger an exception, but we’re not talking about Swift yet, we’re talking about a precursor language that had complete trust in the developer. What will happen here? Will this crash? Will it work?

The short answer is that we don't know. Sometimes you'll get 0, sometimes you'll get a random number, and sometimes you'll get a crash. It completely depends on the contents of that specific memory address will be at that specific point in time, so in other words, the behavior of that assignment is undefined.

But again, this was intentional. The language assumed that you knew what you were doing and allowed you to proceed with it, even though that turned out to almost always be a huge mistake.

Apple was one of the companies at the time that recognised the need for a safer, modern alternative to these languages. While no amount of compiler features can prevent you from introducing logic errors, they believed programming languages should be able to prevent undefined behavior, and this vision eventually led to the birth of Swift: a language that prioritized memory safety.

One of the main focuses of Swift is making undefined behavior impossible, and today this is achieved via a combination of compiler features (like explicit initialization, type-safety, and optional types) and runtime features (like throwing an exception when an array is accessed at an index that doesn’t exist. It’s still a crash, but it’s not undefined behavior anymore because now we know what’s supposed to happen!).

You could argue that this should come with the cost of making Swift an inferior language in terms of power and potential, but one interesting aspect of Swift is that it still allows you to tap into that raw power that came with the precursor languages when necessary. These are usually referred to within the language as “unsafe” operations, and you know when you’re dealing with one because they are literally prefixed with the “unsafe” keyword.

let ptr: UnsafeMutablePointer<Int> = ...

The problem of concurrency in Swift

But despite being designed for memory safety, Swift was never truly 100% memory safe because concurrency was still a large source of undefined behavior in the language.

The primary reason why this was the case is because Grand Central Dispatch (GCD), Apple’s main concurrency solution for iOS apps, was not a feature of the Swift compiler itself, but rather a C library (libdispatch) that was shipped into iOS as part of Foundation. Just as expected of a C library, GCD gave you a lot of freedom in regard to concurrency work, making it challenging for Swift to prevent common concurrency issues like data races, race conditions, deadlocks, priority inversions, and thread explosion.

(If you’re not familiar with one or more of the terms above, here’s a quick glossary:)

  • Data Race: Two threads accessing shared data at the same time, leading to unpredictable results
  • Race Condition: Failing at synchronize the execution of two or more threads, leading to events happening in the wrong order
  • Deadlock: Two threads waiting on each other, meaning neither is able to proceed
  • Priority inversion: Low-priority task holding a resource needed by a high-priority task, causing delays in execution
  • Thread explosion: Excessive number of threads in the program, leading to resource exhaustion and decreased system performance
let semaphore = DispatchSemaphore(value: 0)
highPrioQueue.async {
    semaphore.wait()
    // …
}

lowPrioQueue.async {
    semaphore.signal()
    // …
}

The above is a classic example of a priority inversion in iOS. Although you as a developer know that the above semaphore will cause one queue to wait on another, GCD would not necessarily agree and fail to properly escalate the lower priority queue’s priority. To be clear, GCD can adjust itself in certain situations, but patterns like the above example were not covered by it.

Because the compiler was unable to assist you with such problems, concurrency (and thread safety specifically) historically was one of the hardest things to get right in iOS development, and Apple was well aware of it. In 2017, Chris Lattner, one of the driving forces behind Swift, laid out his vision for making concurrency safe in his Swift Concurrency Manifesto, and in 2020, a roadmap materialized which envisioned new key features to Swift, which included:

  • The async/await pattern
  • Task API and the concept of Structured Concurrency
  • Actors & Actor Isolation

But although what the roadmap proposed was new to the language, it was not new to tech itself. The async/await pattern, which was first introduced in 2007 as a feature of F#, has been an industry standard since 2012 (when C# made it mainstream) due to its ability to allow asynchronous code to be written as traditional synchronous ones, making concurrency-related code easier to read.

For example, before you might write:

func loadWebResource(_ path: String, completionBlock: (result: Resource) -> Void) { ... }
func decodeImage(_ r1: Resource, _ r2: Resource, completionBlock: (result: Image) -> Void)
func dewarpAndCleanupImage(_ i : Image, completionBlock: (result: Image) -> Void)

func processImageData1(completionBlock: (result: Image) -> Void) {
    loadWebResource("dataprofile.txt") { dataResource in
        loadWebResource("imagedata.dat") { imageResource in
            decodeImage(dataResource, imageResource) { imageTmp in
                dewarpAndCleanupImage(imageTmp) { imageResult in
                    completionBlock(imageResult)
                }
            }
        }
    }
}

whereas now you can write:

func loadWebResource(_ path: String) async -> Resource
func decodeImage(_ r1: Resource, _ r2: Resource) async -> Image
func dewarpAndCleanupImage(_ i : Image) async -> Image

func processImageData1() async -> Image {
    let dataResource  = await loadWebResource("dataprofile.txt")
    let imageResource = await loadWebResource("imagedata.dat")
    let imageTmp      = await decodeImage(dataResource, imageResource)
    let imageResult   = await dewarpAndCleanupImage(imageTmp)
    return imageResult
}

Introducing this pattern in Swift specifically would not only improve the experience of working with completion handlers but also technically allow the compiler to detect and prevent common concurrency mistakes. It’s easy to see why Chris Lattner made the pattern the central piece of his manifesto, in which he declared, in his own words: “I suggest that we do the obvious thing and support this in Swift.”

Over the years these features were gradually integrated into Swift, culminating in the Swift 5.5 release and the “official” release of async/await in Swift.

Async/await under the hood

Now that we understand why async/await became a part of Swift, we’re ready to take a look at how it works under the hood!

But first, I have to set some expectations with you. Because what we refer to as “async/await” is actually multiple different compiler features working in unison, and because each of these features is complicated enough to warrant their own separate article(s), there’s no way I can possibly cover every single detail of how it works in just one article. I would lose my sanity within the first section if I did that.

So instead of doing that, I’ve decided that a good plan would be to cover only what I believe to be async/await’s “core” functionality and to leave the remaining bits for future articles. But although we don’t go into details about those other bits here, I still made sure to mention some of them in the appropriate sections for you to know where they come into play. For simplicity, we’re also not going to cover compatibility modes and Obj-C bridging here.

With that said, let’s get started! When I reverse-engineer something to learn more about it, I always start from the bottom and move my way up. So when I decided that I wanted to understand how async/await works, my first question was: “Who is managing the program’s background threads?”

The Cooperative Thread Pool

One of the most important aspects of how async/await works in Swift, and one that we must cover before anything else, is that while async/await technically uses GCD under the hood, it does not use the DispatchQueues that we are familiar with. Instead, what powers async/await is a completely new feature of libdispatch called The Cooperative Thread Pool.

Unlike traditional DispatchQueues, which creates and terminates threads dynamically as it deems necessary, the Cooperative Thread Pool manages a fixed number of threads that are constantly helping each other with their tasks.

The “fixed number”, which in this case is equal to the system’s number of CPU cores, is an intentional move that aims to prevent thread explosion and improve system performance in general, something which DispatchQueues were notoriously not very good at.

In other words, the cooperative thread pool is similar to traditional GCD from an interface perspective (it’s a service that receives a job and arranges some thread to run it), but is more efficient and designed to better suit the Swift runtime’s special needs.

We can see exactly how the Cooperative Thread Pool works by exploring the open-source libdispatch repo, but I would like to reserve that for a future article. In general, I find that this WWDC session from 2021 provides great information about how the pool works internally.

Gotcha: Starving threads in the pool

It must be noted that the fact that the pool holds a fixed number of threads has a significant impact on how asynchronous code is supposed to be written in the language. There is now an expectation that threads should always make forward progress, which means you need to be really careful with when and how you do expensive operations in async/await in order to avoid starving the system's threads. Swift's async/await has many gotchas like this, and we'll uncover some of them as we proceed.

Let’s move up the abstraction layers. How does the compiler “speak” to the pool?

Keep the jobs flowing: The Executors

In Swift, you don't interact with the Cooperative Thread Pool directly. This is hidden by several layers of abstractions, and at the lowest of these layers, we can find the executors.

Executors, just like the pool itself, are services that accept jobs and arrange for some thread to run them. The core difference between them is that while the pool is just, well, the pool, executors come in many shapes and forms. They all end up forwarding jobs to the pool, but the way they do so may change depending on which type of executor you’re using. As of writing, executors can be either concurrent (jobs can run in parallel) or serial (one at a time), and the compiler provides built-in implementations for both of them.

The Built-in Concurrent Executor

The built-in concurrent executor is referred to internally as the Global Concurrent Executor.

The implementation of this executor in the Swift compiler is for the most part nothing more than an abstraction on top of the cooperative thread pool that we mentioned in the previous section. It starts by creating an instance of the new thread pool, which we can see is done by calling the good ol’ GCD API with a special new flag:

constexpr size_t dispatchQueueCooperativeFlag = 4;
queue = dispatch_get_global_queue((dispatch_qos_class_t)priority,
                                  dispatchQueueCooperativeFlag);

Then, when the executor is asked to run a job, it forwards it to the pool via a special dispatch_async_swift_job API:

JobPriority priority = job->getPriority();
auto queue = getGlobalQueue(priority);
  dispatch_async_swift_job(queue, job, (dispatch_qos_class_t)priority,
                  DISPATCH_QUEUE_GLOBAL_EXECUTOR);

I would like to leave the details of libdispatch and dispatch_async_swift_job for another time, but as mentioned in the previous section, this is supposed to be a special/more efficient variant of the regular dispatch_async API that iOS developers are familiar with that better suits the Swift runtime’s special needs.

Another aspect of this executor worth mentioning is that it's "global", meaning there is only one instance of it for the entire program. The reasoning for this is similar to why a serial DispatchQueue would deep-down forward its jobs to the global ones: while from a systems perspective, it makes sense for the responsibilities to appear to be divided, from a performance perspective it would be a nightmare for each component to have their own dedicated threads. It's sensible then to have a single, global executor that will ultimately schedule most of the work in the system, and have everyone else forward their jobs to it.

The Global Concurrent Executor is Swift’s default executor in general. If your async code is not explicitly requesting that it should go through a specific executor (we will see some examples of that as we continue to explore the abstractions), this is the executor that will handle it.

(Swift uses a different global executor in platforms that don’t support libdispatch, but I will not go into details of that as the primary focus of this article is iOS development.)

The Built-in Serial Executor

Unlike the concurrent executor, the purpose of the serial executor is to make sure jobs are executed one by one and in the order in which they were submitted.

The built-in serial executor is referred internally to as the "Default Actor" (spoiler alert!), and it is in its essence an abstraction of the concurrent executor that keeps track of a linked list of jobs:

class DefaultActorImpl : public HeapObject {
public:
  void initialize();
  void destroy();
  void enqueue(Job *job);
  bool tryAssumeThread(RunningJobInfo runner);
  void giveUpThread(RunningJobInfo runner);
}

struct alignas(2 * sizeof(void*)) State {
  JobRef FirstJob;
  struct Flags Flags;
};

enum class Status {
  Idle,
  Scheduled,
  Running,
};

swift::atomic<State> CurrentState;

When a job is enqueued, instead of immediately forwarding it to the concurrent executor, it stores it in the linked list and waits until there’s no one in front of it before truly passing it forward.

static void setNextJobInQueue(Job *job, JobRef next) {
  *reinterpret_cast<JobRef*>(job->SchedulerPrivate) = next;
}

The full extent of what happens when a job is enqueued to the serial executor is one of those things that I said I have to skip in order to maintain my sanity because this executor is responsible for managing a lot of stuff relating to other async/await features (Actor.cpp alone has 2077 lines of code).

But one interesting thing worth mentioning is how it attempts to prevent priority inversions. When a high-priority job is enqueued to a list that previously only had low-priority jobs, the executor escalates the priority of all jobs that came before it.

if (priority > oldState.getMaxPriority()) {
  newState = newState.withEscalatedPriority(priority);
}

As the name implies, the Default Actor serial executor comes into play when writing async code via the Actors feature. We still have a couple of things to understand before we can look into actors though, so let’s move on for now.

Custom Executors

Besides the two built-in executors, it's also possible to build your own custom executor in Swift by creating a type that inherits from the Executor protocol:

public protocol Executor: AnyObject, Sendable {
    func enqueue(_ job: consuming Job)
}

For serial executors specifically, Swift even provides a more specific SerialExecutor protocol:

public protocol SerialExecutor: Executor { ... }

The ability to do so was added in Swift 5.9 alongside the ability to pass custom executors to certain APIs, but there's little reason why you would do such a thing. This was added as a support tool for developers who use Swift in other platforms and is not something an iOS developer would have to deal with. With that said, we do have one very important feature to cover in this article that relies on this ability, but we need to answer a couple more questions before we can look into what that feature is.

Let's keep moving up the abstraction layers. We now know that Swift's built-in executors are the ones passing jobs to the cooperative thread pool, but where do these jobs come from?

Get me those jobs: The async/await pattern

The next piece of the puzzle lies in the async/await pattern itself.

As you might know by now, the async/await pattern consists of two new keywords (async and await) that allow you to define an asynchronous function and wait for an asynchronous function to return, respectively:

func example() async {
    let fooResult = await foo()
    let barResult = await bar()
    doSomething(fooResult, barResult)
}

func foo() async -> FooResult {
    // Some async code
}

func bar() async -> BarResult {
    // Some async code
}

One of the main purposes of the async/await pattern is to allow you to write asynchronous code as if it were straight-line, synchronous code, and this might give you the impression that deep down this feature is just a compiler pass that is dividing a function into multiple components. This definition is important in order to understand how the machine is operating, but in reality, things are a lot more sophisticated than that!

Instead of thinking of an asynchronous function as just a syntax sugar for declaring a bunch of closures, think of it as an ordinary function that has the special power to give up its thread and wait for something to happen. When that thing is complete, the function bootstraps itself back up and resumes its execution.

This means that apart from how they wait for things to happen, asynchronous functions and synchronous ones are (sort of) the same thing in Swift! The only difference is that while the synchronous function gets to take full advantage of the thread and its stack, the asynchronous ones have the extra power of giving up that stack and maintaining their own, separate storage.

Although our main interest here is exploring memory safety, one interesting thing to mention is how this definition is important from a code architecture perspective; because asynchronous functions in Swift are effectively the same as synchronous ones, this means you can use them for things that you previously couldn’t do with completion handler closures, such as marking a function as throws:

func foo() async throws {
    // …
    throw MyError.failed // Can’t do this without async/await!
}

But enough theory. How does it work?

Execution Contexts

We can start understanding how the pattern is implemented by looking at what the Swift compiler does when it processes a line of code marked as await. By compiling the above example code with the -emit-sil flag, we can that the example’s Swift Intermediate Language output looks something like this (greatly simplified for readability):

// example()
sil hidden @$s4test7exampleyyYaF : $@convention(thin) @async () -> () {
bb0:
  hop_to_executor foo
  foo()
  hop_to_executor example
  hop_to_executor bar
  bar()
  hop_to_executor example
  return void
} // end sil function '$s4test7exampleyyYaF'

The SIL of an async function looks exactly the same as the one from a regular synchronous function would, with the difference that Swift calls something called hop_to_executor before and after an await function is supposed to be called. According to the compiler’s documentation, the purpose of this symbol is to make sure that the code is running in the right executor. Hmmmm.

One important memory safety feature of Swift’s async/await is what it refers to as execution contexts. As we briefly mentioned when we talking about executors, whenever something runs asynchronously in Swift through async/await, it has to go through a specific executor; the majority of code will go through the default global concurrent one, but certain APIs may use different ones.

The reason why certain APIs may have specific executor requirements is to prevent data races. We’re not ready to explore this topic yet though, so for now just keep in mind that this is why different executors exist.

What hop_to_executor does in practice is check the current execution context. If the executor that the function is currently running on is the same as the function that we want to await expects, the code will run synchronously. But if it’s not, a suspension point is created; the function requests the necessary code to run in the correct context and gives up its thread while it waits for the result. This “request” is the job that we were looking for, and the same will happen when the job finishes in order to return to the original context and run the rest of the code.

func example() async {
    (original executor)
    let fooResult = await foo() // POTENTIAL job 1 (go to foo’s executor)
    // POTENTIAL job 2 (back to original context)
    let barResult = await bar()  // POTENTIAL job 3 (go to bar’s executor)
    // POTENTIAL job 4 (back to original context)
    doSomething(fooResult, barResult)
}

The word potential here is very important: As just mentioned, a suspension point is only created if we’re in the wrong context; If no context hopping is needed, the code will run synchronously. This is something that DispatchQueues notoriously could not do, and is a very welcome ability that we will mention again later in this article.

In fact, since await only marks a potential suspension point, this has the interesting side-effect of allowing async protocol requirements to be fulfilled by regular, synchronous ones:

protocol MyProto {
    func asyncFunction() async
}

struct MyType: MyProto {
    func asyncFunction() {
        // This is not an async function, but the Swift is fine with it
        // because `async` and `await` doesn’t mean that the
        // function is _actually_ async, only that it _may_ be.
    }
}

This is also why you can call synchronous functions from asynchronous ones but not vice-versa; asynchronous functions know how to synchronously wait for something, but synchronous ones don’t know how to create suspension points.

Suspension points are a major win for memory safety in Swift: because they result in the thread being released (as opposed to how a lock, semaphore, or DispatchQueue.sync would hold onto it until the result arrived), this means that deadlocks cannot happen in async/await! As long as you’re not mixing async/await code with other thread-safety mechanisms (which Apple says you shouldn’t in their 2021 session), your code will always have a thread in which it can run.

Gotcha: Reentrancy

It must be noted though that this behavior has an important gotcha in terms of code architecture. Because suspension points may give up their thread while waiting for a result, it can (and will) happen that the thread that originated the request may start running other jobs while it waits for the result to arrive! In fact, unless you’re using Main Actors (which we will explore in detail later on), there’s no guarantee that the thread that will process the result will even be the same one that originated the request!

func example() async {
    doSomething() // Running in thread A
    await somethingElse()
    doSomethingAgain() // This COULD also be running in thread A, but it’s probably not!
    // Also, thread A has likely moved on to do other things while we were waiting for somethingElse()!
}

This means that in order to implement thread-safe objects in async/await, you must structure your code in a way so that it’s never assuming or carrying state across suspension points because any assumptions that you made about the program’s state prior to the suspension point might not be true anymore after the suspension point. This behavior of async/await is called reentrancy and is something we’ll explain in more detail further below when we start speaking about race conditions specifically. In short, reentrancy in Swift’s async/await is intentional, and is something you must keep in mind at all times when working with async/await code in Swift.

I would like to show you how exactly these suspension points and the re-bootstrapping work in the compiler’s code, but as of writing, I was not able to properly understand it. I’d still like to do that though, so I’ll update this article once I figure that out.

We still have one important puzzle piece to investigate though. If synchronous functions are not allowed to call asynchronous ones because they don’t have the power to create a suspension point, what is the “entry point” for an asynchronous function?

Tasks and Structured Concurrency

In Swift’s async/await, the way you call an asynchronous function the first time is by creating a Task object:

Task {
    await foo()
}

Because the closure of a task object is itself marked as async, you can use it to call other asynchronous functions. This is the “entry point” we were looking for.

Swift’s Task struct has a much bigger role than simply allowing you to call async code; they form a fundamental part of what Swift calls "Structured Concurrency," where asynchronous code is structured as a hierarchy of "tasks." This structuring allows parent tasks to manage their child tasks, sharing information like status, context, priority, and local values, as well as enabling the creation of child "task groups" that comprise multiple tasks that run in parallel. Structured Concurrency forms the backbone of Swift's async/await architecture, but is a topic large enough to warrant its own article. For the purposes of this article, we’re going to focus only on the core functionality of tasks.

Let’s get back to the original question. How is Task managing to create an async closure out of nowhere?

The key to understanding how Task bootstraps an async closure lies in its initializer. When a Task is created, the closure it captures is managed not by the Task struct itself, but by a function that lives deep within the Swift runtime:

extension Task where Failure == Never {
  public init(
    priority: TaskPriority? = nil,
    @_inheritActorContext @_implicitSelfCapture operation: __owned @Sendable @escaping () async -> Success
  ) {
    let flags = taskCreateFlags(
      priority: priority, isChildTask: false, copyTaskLocals: true,
      inheritContext: true, enqueueJob: true,
      addPendingGroupTaskUnconditionally: false,
      isDiscardingTask: false)

    let (task, _) = Builtin.createAsyncTask(flags, operation)
    self._task = task
  }
}

The call to Builtin.createAsyncTask ultimately results in a call to swift_task_create in the Swift runtime, which creates a task based on a couple of flags that configure how the task should behave. The compiler conveniently takes that of that configuration automatically for you, and once the task is set up, it is immediately directed to the appropriate executor for execution.

static AsyncTaskAndContext swift_task_create_commonImpl(…) {
  // The actual function is a lot more complicated than this.
  // This is just a pseudo-coded simplification for learning purposes.

  task.executor = task.parent.executor ?? globalConcurrentExecutor;

  task.checkIfItsChildTask(flags);
  task.checkIfItsTaskGroup(flags);
  task.inheritPriorityFromParentIfNeeded(flags);

  task.asJob.submitToExecutor();
}

Structured Concurrency is the reason why the compiler knows all of this information. Similarly to how the serial executor tracks a linked list of jobs, the Swift runtime tracks a graph of all tasks running concurrently in the program. This tracking, in combination with a secondary map connecting asynchronous functions to the tasks that invoked them, allows Swift to infer all the necessary information to bootstrap a task, including the ability to make adjustments such as escalating the priority of a child task based on their parent's priority.

Interestingly enough, Swift actually provides you with APIs that allow you to access these graphs in your Swift code, although they make it very clear that should only be used in special cases. One example of this is withUnsafeCurrentTask, which allows functions to determine if they were called as part of a task.

func synchronous() {
  withUnsafeCurrentTask { maybeUnsafeCurrentTask in 
    if let unsafeCurrentTask = maybeUnsafeCurrentTask {
      print("Seems I was invoked as part of a Task!")
    } else {
      print("Not part of a task.")
    }
  }
}

Gotcha: Accidental Task Inheritance

Because child tasks by default inherit the properties of their parent, and because the runtime handles that automatically for you, you might end up in situations where a task is inheriting things you didn't mean to:

func example() async {
  Task {
    // This is NOT a parentless task, as much as it looks like one!
  }
}

In the example above, what looks like a "bland" task is actually a child task of whatever job led to example() being called! This means this task will inherit that parent's properties, which may include things you don't want this particular task to inherit, such as the executor. One example case where this can be a problem is when dealing with code that interacts with the MainActor, which we will explore in detail further below.

In order to avoid this, you must use alternate task initializers like Task.detached which define "unstructured" tasks with no parent, but it must be noted that they also have their own gotchas, so make sure to read their API documentation before using them.

We’ve now covered all the core mechanics of async/await, but we still have one question left to answer. We’ve seen how async/await is able to prevent thread explosion, priority inversions, and deadlocks, but what about data races? We know that the concept of “execution contexts” is what’s supposed to prevent it, but we haven’t seen that in practice yet.

We also haven’t even begun to talk about the infamous race conditions that plague every iOS app. What does Swift’s async/await do to protect you from those?

Protecting shared mutable state: Actors

We have left Actors to last because they don’t relate to the core functionality of async/await, but when it comes to memory safety, they are just as important as the other features we’ve covered.

In Swift, an “actor” is a special type of class that is marked with the actor keyword:

actor MyExample {
    var fooInt = 0
}

Actors are mostly the same as classes, but they contain a special power: any mutable state managed by an actor can only be modified by the actor itself:

func foo() {
    let example = MyExample()
    example.fooInt = 1 // Error: Actor-isolated `fooInt`
    // cannot be mutated from a non-isolated context
}

In the example above, in other to mutate fooInt, we must somehow abstract that action so that it happens within the bounds of the actor:

actor MyExample {
    var fooInt = 0
    func mutateFooInt() {
        fooInt = 1
    }
}

This looks like it would make no difference, but this is where the actors’ second special power comes into play: only the actor is allowed to synchronously reference its methods and properties; everyone else must do it asynchronously:

func foo() {
    let example = MyExample()
    Task {
        await example.mutateFooInt()
        // The actor itself is allowed to call mutateFooInt() synchronously,
        // but the example() function is not.
    }
}

This is a concept called actor isolation, and when combined with the concept of execution contexts we’ve seen above, Swift’s async/await is able to prevent you from introducing potential data races in your program. To make it better, those checks happen in compile time!

To be more specific, when you await on an actor, your code will be forwarded not to the default global concurrent executor, but a serial one that was created specifically for that actor instance. This has the effect of not allowing you to call two actor functions at the same time (one will end before the other one starts), and when combined with the fact that the compiler doesn’t allow you to “leak” an actor’s mutable state, you have essentially a situation where it’s not possible for your actor’s state to be mutated by two threads at the same time. But how does this work internally?

When it comes to their implementations, actors are surprisingly straightforward. In Swift, declaring an actor is just a syntax sugar for declaring a class that inherits from the Actor protocol:

public protocol Actor: AnyObject, Sendable {
    nonisolated var unownedExecutor: UnownedSerialExecutor { get }
}

The only property of the protocol is unownedExecutor, which is a pointer to the serial executor that is supposed to manage the jobs related to that actor. The purpose of the UnownedSerialExecutor type is to wrap a type conforming to the SerialExecutor protocol we saw previously as an unowned reference, which the documentation describes as necessary for optimization reasons.

public struct UnownedSerialExecutor: Sendable {
    internal var executor: Builtin.Executor
    public init<E: SerialExecutor>(ordinary executor: __shared E) {
      self.executor = Builtin.buildOrdinarySerialExecutorRef(executor)
    }
}

When you declare an actor via the syntax sugar, Swift automatically generates this conformance for you:

// What you write:
actor MyActor {}

// What is compiled:
final class MyActor: Actor {

    nonisolated var unownedExecutor: UnownedSerialExecutor {
        return Builtin.buildDefaultActorExecutorRef(self)
    }

    init() {
        _defaultActorInitialize(self)
    }

    deinit {
        _defaultActorDestroy(self)
    }

}

We already know what this generated code is doing; it initializes the Default Actor serial executor that we’ve covered at the beginning. Since actors are deeply ingrained into Swift, the compiler knows that whenever someone references it, the eventual call to hop_to_executor should point to the actor’s unownedExecutor property and not the global one.

Gotcha: Actor Reentrancy (Actors and thread-safety)

While actors naturally protect you from data races, it’s critical to remember that they cannot protect you from logic mistakes like race conditions / straight-up incorrect code. We have already covered why this is the case when we talked about suspension points and reentrancy, but I’d like to reiterate this because this is extra important when working with actors specifically.

When a suspension point is created, the actor will allow other jobs in the serial queue to run. This means that when the result for the original job finally arrives, it’s possible that the actor’s state may have changed in a way where whatever assumptions you made before the suspension point are no longer true!

In actors specifically, this is referred to as Actor Reentrancy, and is once again something you must keep in mind at all times when attempting to write thread-safe code with async/await. As suggested in the section about reentrancy in general, in order for your actors to be thread-safe, you must structure your code so that no state is assumed or carried over across suspension points.

Sendable and nonisolated

Like in the case of deadlocks, an actor’s solution for data races has important consequences in terms of code architecture. If you cannot “leak” an actor’s mutable state, how does anything ever happen?

Swift’s async/await provides two features to address this. The first one is the Sendable protocol, which marks types that can safely leave an actor:

public protocol Sendable { }

This protocol has no actual code; it’s simply a marker used by the compiler to determine which types are allowed to leave the actors that created them. This doesn’t mean that you can mark anything as Sendable through; Swift really doesn’t want you to introduce data races into your programs, so the compiler has very strict requirements of what can inherit it:

  • Actors (does so by default)
  • Value types
  • final classes that have no mutable properties
  • Functions and closures (by marking them with @Sendable)

Gotcha: Sendable contagion

While Sendable solves this problem, it must be noted that this protocol has been the target of criticism in the Swift community due how the necessity of tagging “safe” types combined with how the compiler has the tendency to behave like an overprotective mother (it will complain that a type must be Sendable even when in situations where no data race could possibly happen) can quickly cause Sendable to “plague” your program’s entire architecture. There have been pitches on potential improvements in this area, but I believe as of writing no formal proposals have been submitted yet.

Aside from Sendable, the nonisolated keyword is also intended to assist with the problem of having to “leak” an actor’s state. As the name implies, this allows you to mark functions and properties that are allowed to ignore the actor’s isolation mechanism:

actor BankAccount {
    nonisolated let accountNumber: Int
}

When referenced, the compiler will pretend that the type didn’t originate from an actor and skip any and all protection mechanisms that would normally apply. However, similarly to Sendable, not everything can be marked as nonisolated. Only types that are Sendable can be marked as such.

Actors and the Main Thread

At this point, we’ve covered everything we needed regarding async/await in Swift, but there’s still one thing we still need to cover regarding iOS development specifically. Where’s the main thread in all of this?

We’ve talked a lot about the new thread pool and how executors interact with them, but iOS developers will know that UI work always needs to run in the main thread. How can you do that if the cooperative thread pool has no concept of a “main” thread?

In Swift, this is where the ability to build custom executors that we’ve seen at the beginning of the article comes into play. Swift’s standard library ships a type called MainActor, which as the name implies, is a special type of actor that synchronizes all of its jobs to the main thread:

@globalActor public final actor MainActor: GlobalActor {
  public static let shared = MainActor()

  public nonisolated var unownedExecutor: UnownedSerialExecutor {
    return UnownedSerialExecutor(Builtin.buildMainActorExecutorRef())
  }

  public nonisolated func enqueue(_ job: UnownedJob) {
    _enqueueOnMain(job)
  }
}

The MainActor achieves this by overriding the default unownedExecutor with a custom Builtin.buildMainActorExecutorRef() one. Since we’re telling Swift that we don’t want to use the default serial executor for this actor, this will deep down cause the Swift runtime to call the MainActor’s custom-defined enqueue method instead.

In the case of MainActor, the call to _enqueueOnMain will cause the job to be forwarded to the global concurrent executor as usual, but this time via a special function that causes the job to be submitted to GCD’s main queue instead of the cooperative thread pool.

// The function where “regular” async/await jobs ends up in
static void swift_task_enqueueGlobalImpl(Job *job) {
  auto queue = getCooperativeThreadPool();
  dispatchEnqueue(queue, job);
}

// The function where MainActor jobs ends up in
static void swift_task_enqueueMainExecutorImpl(Job *job) {
  auto mainQueue = dispatch_get_main_queue();
  dispatchEnqueue(mainQueue, job);
}

In other words, code executed by the main actor is essentially the same thing as calling DispatchQueue.main.async, although not literally the same due to two facts that we have already covered: the fact that the Swift runtime uses a “special” version of DispatchQueue.async to submit its jobs, and the fact the dispatch will technically not happen if we’re already inside the main thread (MainActor’s “execution context”).

// What you write:
Task {
    await myMainActorMethod()
}

// What (sort of) actually happens:
// (Actual behavior explained above)
Task {
    DispatchQueue.main.async {
        myMainActorMethod()
    }
}

Global Actors

The final thing I’d like to show you is how actors like the MainActor are used in practice. We know that regular actors are created and passed around as normal objects, but doing so with the MainActor would not scale well. Even though the MainActor is available as a singleton, there’s a lot of stuff that needs to run in the main thread in iOS, so if we were treating it like a regular object, we would end up with a lot of code looking like this:

extension MainActor {
    func myMainActorMethod() {}
}

func example() {
    Task {
        await MainActor.shared.myMainActorMethod()
    }
}

///////////// or:

func example() {
    Task {
        await MainActor.run {
            myMainActorMethod()
        }
    }
}

func myMainActorMethod() {}

Although both solutions “work”, Swift saw potential for improvement by creating the concept of “global actors”, which describe actors that can not only be referenced but also expanded from anywhere in the program. Instead of forcing everyone to reference singletons everywhere, Swift's Global Actors feature allows you to easily indicate that a certain piece of code should be executed within the bounds of a specific global actor by marking it with a special annotation:

@MainActor
func myMainActorMethod() {}

This is essentially the same thing as the examples shown above, but with much less code. Instead of having to reference the MainActor’s singleton, we can now directly reference this method and be sure that it will be executed within the MainActor’s context.

func example() {
    await myMainActorMethod() // This method is annotated as @MainActor,
    // so it will run in the MainActor’s context.
}

In order to be able to do this, the actor in question must be marked with the @globalActor keyword, and is something that you can do for your own actors if you find that this behavior would be useful for them. As one would expect, the MainActor is itself a global actor.

Marking an actor as @globalActor is deep down a syntax sugar for declaring an actor that inherits from the GlobalActor protocol, which is essentially a variation of the regular Actor protocol that additionally defines a singleton that Swift can refer to when it finds one of those special annotations across the program.

public protocol GlobalActor {
  associatedtype ActorType: Actor
  static var shared: ActorType { get }
}

Then, during compilation time, when Swift encounters one of those annotations, it follows up by emitting the underlying hop_to_executor call with a reference to that actor’s singleton.

func example() {
    // SIL: hop_to_executor(MainActor.shared)
    await myMainActorMethod()
    // SIL: hop_to_executor(DefaultExecutor)
}

Conclusion: Swift's async/await makes concurrency simpler, but not necessarily easier

In general, I like async/await. I think this is a nice addition to Swift, and it makes working with concurrency a lot more interesting.

But you must not get this wrong. Although Swift prevents you from making memory-related mistakes, it does NOT prevent you from making logic mistakes / writing straight-up incorrect code, and the way the feature works today makes it very easy for you to introduce such mistakes. We've covered some of the pattern's gotchas in this article, but there are many more of them pertaining to features we didn't get to explore here.

Matt Massicotte's "The Bleeding Edge of Swift Concurrency" talk from Swift TO 2023 goes into more detail about gotchas in async/await, and I believe is a talk that anyone working with async/await in Swift should watch.

For more information on thread safety in Swift specifically, check out my article about it.