How Actors Work Internally in Swift

How Actors Work Internally in Swift

2023-09-28 Update: Instead of reading this article, check out "How async/await works internally in Swift" instead. This article was written shortly after async/await was released and is full of incorrect information. The async/await one explains the same concepts and features, but in a lot more detail and with the mistakes corrected. I'm keeping it here for preservation purposes, but you read the async/await one instead.

Actors is a feature that is part of Swift's Structured Concurrency, bringing a brand new format to write and deal with asynchronous code. Although what Swift brings is new to the language, it's not new to tech itself. Many languages supported actors and async/await before Swift, but one interesting thing is that they are all implemented similarly. By having these features now in Swift, there's much we can learn from what developers experienced by using them in other languages.

Just like other "How X works internally in Swift" SwiftRocks articles, the purpose of this article is to explore how actors work under the hood, using Swift's own source code as a guide to finding out how they work inside the compiler.

What's an actor?

The purpose of the actor feature is to help prevent race conditions in the state of asynchronous classes. Although this is not a new concept, actors are part of a much bigger investment. While in theory you can reproduce everything an actor is doing by simply adding NSLocks to your classes' properties/methods, in practice they have a couple of important bonuses. First of all, the synchronization mechanism used by actors is not the locks we know, but async/await's new Cooperative Threading Model in which threads can seamlessly "change" contexts to execute other pieces of code to avoid ever having idle threads, and second of all, the presence of actors allow the compiler to check for many concurrency issues in compile time, letting you know right off the beat if something is potentially dangerous:

actor MyActor {
    var myProp = 0
}

MyActor().myProp
// error: actor-isolated property 'myProp' can only be referenced from inside the actor

In this case, one of the reasons why myProp can only be accessed from within the actor is that you can only use actors from within an async context, because the actor's synchronization implies that there's no guarantee that your code will be executed synchronously. Because of that, every actor method is implicitly async unless stated otherwise (shown later on).

Task {
    await actor.getMyProp()
}

How do actors work?

One thing I like to mention is how most Swift language features are based on actual Swift code, and this is partially the case with actors too. In practice, actors are a syntax sugar for classes that inherit the Actor protocol:

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

This relation is automatically generated when the actor class is compiled, and like some other Swift features, you can actually reproduce this yourself:

final class MyCustomActor: Actor {}

In practice however, this won't work as a regular actor. Although this code works, the compiler will ask you to manually implement the unownedExecutor requirement and its synchronization mechanisms. This was a bit surprising to be honest, because some other features like the automatic Codable synthesis can automatically fill the gaps when you provide part of the implementation like in this example. Regardless of the intention, in the current Swift 5.5 build you can only have the complete actor implementation by using the actor keyword.

The protocol states that all actors must also be Sendable, which is another new important piece of the concurrency proposal. This protocol has no actual code, and its purpose is to "mark" which types are safe to use in a concurrent environment. Even though actors by themselves are "safe", you could still have race condition issues if you, for example, used reference types as your state and leaked it outside the actor. To avoid this, Swift uses Sendable to indicate which types are thread-safe by design, which actually ends up being the backbone of the compile-time errors we saw before. Only immutable content like structs and final classes can inherit from Sendable, with UnsafeSendable being available as a workaround that skips all compile-time static analysis. As far I could tell however it appears that the implementation of Sendable is not yet complete, as it was stated in WWDC 2021 that a future intention is to stop actors from being able to leak non-Sendable types.

Executors

The most important aspect of the Actor protocol however is its required property: a nonisolated unownedExecutor. The Executor protocol was added in Swift 5.5 to define an object that can perform a "job", which in the case of actors are the methods themselves:

/// A service which can execute jobs.
@available(SwiftStdlib 5.5, *)
public protocol Executor: AnyObject, Sendable {
  func enqueue(_ job: UnownedJob)
}

In the same fashion, a SerialExecutor defines an object that performs jobs serially:

/// A service which can execute jobs.
@available(SwiftStdlib 5.5, *)
public protocol SerialExecutor: Executor {
  /// Convert this executor value to the optimized form of borrowed
  /// executor references.
  func asUnownedSerialExecutor() -> UnownedSerialExecutor
}

Finally, an UnownedSerialExecutor, which is the Actor protocol's actual requirement, is simply an unowned reference to a SerialExecutor. As far as I could tell, this exists for optimization reasons.

let myExecutor = MySerialExecutor()
let unownedSerialExecutor = UnownedSerialExecutor(ordinary: myExecutor)

You might have noticed that this property contains a new keyword: nonisolated, which is what allows you to "override" Swift's compile-time checks for actors. Like we mentioned before with Sendable, Swift will try to make sure your actors are safe as possible, but there might be cases where this is a mistake. If something is truly meant to be accessed concurrently and on-demand, you can use the nonisolated keyword to detach it from Swift's checks. In this case, the executor needs to be detached from the protections so that we have an actual entry-point to the actor.

Swift automatically generates an executor for your actors, but before seeing how that works, we should see executors in action in another new feature: Global Actors.

Executors in Global Actors

Global Actors exist to cover the fact that state synchronization is not limited to local variables, meaning that you might need to have global access to an actor. Instead of forcing everyone to write singletons everywhere, Swift 5.5'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. Perhaps the most important example of this is MainActor, which is a global actor that makes sure all code is executed in the main thread.

@MainActor doSomethingInMain() {
    something() // Will always be executed in main
}

It's possible to create your own global actors by adding the @globalActor attribute to an actor. By default, Swift will treat your actor as a regular one and generate a default executor for you, but since this is a protocol requirement, you actually override it and create your own synchronization mechanism! In fact, this is exactly how MainActor works -- async/await's threading is based not on DispatchQueues, but the new Cooperative Threading Model runtime feature mentioned in the beginning of this article. We shall explore this in a separate article about async/await, but in short, the main thread is not part of this new model, so actors cannot execute code in the main thread by default. In practice, the way MainActor achieves this is by defining a custom SerialExecutor, which in Swift's source code is the MainActor itself.

/// A singleton actor whose executor is equivalent to the main
/// dispatch queue.
@available(SwiftStdlib 5.5, *)
@globalActor public final actor MainActor: SerialExecutor {
  public static let shared = MainActor()

  @inlinable
  public nonisolated var unownedExecutor: UnownedSerialExecutor {
    return asUnownedSerialExecutor()
  }

  @inlinable
  public nonisolated func asUnownedSerialExecutor() -> UnownedSerialExecutor {
    return UnownedSerialExecutor(ordinary: self)
  }

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

When you call an actor method, Swift will replace your code with a call to the actor's executor's enqueue(_:) method. This is why actors can only be used in async contexts -- you're essentially doing the equivalent of a DispatchQueue.async in the old world! This is how a call to MainActor looks in practice:

Task {
    await myMainActorMethod()
}

Under the scenes however, Swift will process this as something akin to this:

Task {
    MainActor.shared.unownedExecutor.enqueue {
        myMainActorMethod()
    }
}

Which, in the case of MainActor, is essentially a call to DispatchQueue.main.

DispatchQueue.main.async {
    myMainActorMethod()
}

I should let you know that this is not entirely accurate because the functionality of actors (and the executors specifically) are in practice tightly connected to async/await and its new Task model, but I shall ignore this for now and explore the details of that in a separate article about how async/await itself works.

In short, what we're looking at here is that though Swift generates all the implementation of an actor for you, it's still possible for you to override its behavior. In my opinion though I can't see a single reason why someone would need to do this besides the main thread case that Swift already provides to you, so you should probably never attempt to do this yourself.

Swift's Default Actor

Now that we understand how executors are used, we're ready to investigate how actors actually work. Swift's actor behavior is based on something called a default actor, which could be explained as a base class that handles all the synchronization needs of the actor. When you define an empty actor, this is how it'll look like after the code is compiled:

actor MyActor {}

// Compiled:

final class MyActor: Actor {
    var unownedExecutor: UnownedSerialExecutor {
        return Builtin.buildDefaultActorExecutorRef(self)
    }

    init() {
        _defaultActorInitialize(self)
    }

    deinit {
        _defaultActorDestroy(self)
    }
}

In this case, the implementation of the executor is not a separate object like with MainActor, but a reference to the actor itself. This is where things differ from other features we covered in the past: The actual functionality of the actor is not Swift code, but a C++ class:

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

The reason for this is that, as mentioned before, threading in actors is not done by DispatchQueues, but a new language and runtime feature that was introduced alongside async/await. This is why you need iOS 15 to use actors or async/await -- these are not simply Swift improvements, they required changes in iOS itself.

The functionality of the default actor is heavily connected to the functionality of async/await, so we shall skip some concepts for now. But in regards to queueing, the default actor is mostly a state machine that holds a linked list of jobs:

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

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

swift::atomic<State> CurrentState;

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

When a job is enqueued, the default actor grabs its old state and appends the new job.

// The actual implementation of this method is considerably more complicated.
// This is simplified for educational purposes.
void DefaultActorImpl::enqueue(Job *job) {
  auto oldState = CurrentState.load(std::memory_order_relaxed);
  setNextJobInQueue(job, oldState.FirstJob);
}

The actual execution of these jobs is what's heavily connected to async/await, but we can still take a quick look at it. First of all, the execution of a job inside an actor has a priority attached to it, which comes from async/await's Task object. When a job is enqueued, its priority is adjusted to make sure it's executed before other less prioritized events.

auto oldPriority = oldState.Flags.getMaxPriority();
auto newPriority =
  wasIdle ? job->getPriority()
  : std::max(oldPriority, job->getPriority());
newState.Flags.setMaxPriority(newPriority);

When a job is enqueued, the actor registers the job is something called a global executor, which is essentially the C++ implementation of the class that handles async/await's Cooperative Threading Model. In short, actors can own and yield threads, and the global executor will notify an actor when it's allowed to own a specific thread. When this happens, the default actor will execute the first job in the queue and yield the thread.

// Note that this is not the actual implementation but an oversimplification of it.
static void processDefaultActor() {
    auto job = claimNextJobOrGiveUp();
    runJobInEstablishedExecutorContext(job);
    giveUpThread();
}

Additionally, when an actor's method contains an await call, the actor will actually yield the thread in the middle of the execution of the job, allowing other actors to be executed while this one is waiting. When a result is available, a new job will be enqueued for the actor to pick up later on in a (possibly) different thread. This is what the concurrency model describes as Actor Reentrancy, and is why you should be careful with thread-sensitive content like DispatchSemaphores in async/await code -- there's no guarantee that the thread that started a job will be the one that continues it.

Conclusion

As we've seen, the execution side of Swift's concurrency model centers around scheduling work onto various execution services. Although this execution is available as a high-level Swift protocol, the actual implementation of an actor needs to be a lower-level C++ mechanism due to the new runtime features required by async/await. In a future article, we shall explore async/await and its Cooperative Threading Model.

References and Good Reads

Actor.swift
Actor.cpp
Executor.swift
GlobalExecutor.cpp