How @MainActor works

@MainActor is a Swift annotation to coerce a function to always run on the main thread and to enable the compiler to verify this. How does this work? In this article, I’m going to reimplement @MainActor in a slightly simplified form for illustration purposes, mainly to show how little “magic” there is to it. The code of the real implementation in the Swift standard library is available in the Swift repository.

@MainActor relies on two Swift features, one of them unofficial: global actors and custom executors.

Global actors

MainActor is a global actor. That is, it provides a single actor instance that is shared between all places in the code that are annotated with @MainActor.

All global actors must implement the shared property that’s defined in the GlobalActor protocol (every global actor implicitly conforms to this protocol):

@globalActor
final actor MyMainActor {
  // Requirements from the implicit GlobalActor conformance
  typealias ActorType = MyMainActor
  static var shared: ActorType = MyMainActor()

  // Don’t allow others to create instances
  private init() {}
}

At this point, we have a global actor that has the same semantics as any other actor. That is, functions annotated with @MyMainActor will run on a thread in the cooperative thread pool managed by the Swift runtime. To move the work to the main thread, we need another concept, custom executors.

Executors

A bit of terminology:

  • The compiler splits async code into jobs. A job roughly corresponds to the code from one await (= potential suspension point) to the next.
  • The runtime submits each job to an executor. The executor is the object that decides in which order and in which context (i.e. which thread or dispatch queue) to run the jobs.

Swift ships with two built-in executors: the default concurrent executor, used for “normal”, non-actor-isolated async functions, and a default serial executor. Every actor instance has its own instance of this default serial executor and runs its code on it. Since the serial executor, like a serial dispatch queue, only runs a single job at a time, this prevents concurrent accesses to the actor’s state.

Custom executors

As of Swift 5.6, executors are an implementation detail of Swift’s concurrency system, but it’s almost certain that they will become an official feature fairly soon. Why? Because it can sometimes be useful to have more control over the execution context of async code. Some examples are listed in a draft proposal for allowing developers to implement custom executors that was first pitched in February 2021 but then didn’t make the cut for Swift 5.5.

@MainActor already uses the unofficial ability for an actor to provide a custom executor, and we’re going to do the same for our reimplementation. A serial executor that runs its job on the main dispatch queue is implemented as follows. The interesting bit is the enqueue method, where we tell the job to run on the main dispatch queue:

final class MainExecutor: SerialExecutor {
  func asUnownedSerialExecutor() -> UnownedSerialExecutor {
    UnownedSerialExecutor(ordinary: self)
  }

  func enqueue(_ job: UnownedJob) {
    DispatchQueue.main.async {
      job._runSynchronously(on: self.asUnownedSerialExecutor())
    }
  }
}

We’re responsible for keeping an instance of the executor alive, so let’s store it in a global:

private let mainExecutor = MainExecutor()

Finally, we need to tell our global actor to use the new executor:

import Dispatch

@globalActor
final actor MyMainActor {
  // ...
  
  // Requirement from the implicit GlobalActor conformance
  static var sharedUnownedExecutor: UnownedSerialExecutor {
    mainExecutor.asUnownedSerialExecutor()
  }

  // Requirement from the implicit Actor conformance
  nonisolated var unownedExecutor: UnownedSerialExecutor {
    mainExecutor.asUnownedSerialExecutor()
  }
}

That’s all there is to reimplement the basics of @MainActor.

Conclusion

The full code is on GitHub, including a usage example to demonstrate that the @MyMainActor annotations work.

John McCall’s draft proposal for custom executors is worth reading, particularly the philosophy section. It’s an easy-to-read summary of some of the design principles behind Swift’s concurrency system:

Swift’s concurrency design sees system threads as expensive and rather precious resources. …

It is therefore best if the system allocates a small number of threads — just enough to saturate the available cores — and for those threads [to] only block for extended periods when there is no pending work in the program. Individual functions cannot effectively make this decision about blocking, because they lack a holistic understanding of the state of the program. Instead, the decision must be made by a centralized system which manages most of the execution resources in the program.

This basic philosophy of how best to use system threads drives some of the most basic aspects of Swift’s concurrency design. In particular, the main reason to add async functions is to make it far easier to write functions that, unlike standard functions, will reliably abandon a thread when they need to wait for something to complete.

And:

The default concurrent executor is used to run jobs that don’t need to run somewhere more specific. It is based on a fixed-width thread pool that scales to the number of available cores. Programmers therefore do not need to worry that creating too many jobs at once will cause a thread explosion that will starve the program of resources.