This post isn’t meant to explain how actors work. Rather just elaborate a few tiny details.

I’m often curious about the origins of things. How people met, dated or how they began their careers or founded their companies. Similarly about namings, understanding the context often gives you a foundational understanding. I give feedback in Pull Requests about variable and type names or seek advice through code-review channels. First time it occurred was when someone explained that the reduce function means “how you jam and reduce an entire array into a single value”. See here for other posts by where I discuss the naming of something.

The origin of this post was due to my curiosity about the term “actor” in programming. It originates from the seminal 1973 paper by Carl Hewitt, Peter Bishop, and Richard Steiger, a work that predates my own creation.

Why is it named actor?

The term isn’t meant to fully match the meaning of actor as we perceive it to be in movies, but that’s the origin of it.

Intuitively, an ACTOR is an active agent which plays a role on cue according to a script.

This is the core metaphor: just like a stage actor waits for a cue, then performs a role, a computational actor waits for a message, then performs a behavior. So the naming is deliberately theatrical.

Potential Renames of actor?

  • ThreadSafe: Although actors may use threads, they’re not threads themselves.
  • GuardedClass / ProtectedClass / SafeClass: It’s ambagious what’s being guarded protected.
  • ConcurrentActor: You can create an actor with a custom executor that’s serial.

Similar to how the names of struct, class, protocol, etc don’t fully convey all the semantics that they carry, the name actor doesn’t carry all its semantics. The renames fail to clarify while also creating other problems. Given the complexity of actors, choosing a vanilla name will give it room to be later explained in depth. So try to not read too much into the name. Instead go watch the WWDC 2021 - Protect mutable state with Swift actors and play around with it.

In programming world, why does code become asynchronous?

Functions can be asynchronous because:

1 - Asynchronous through their own execution

The task you’re doing takes a while to finish.

func downloadImage() async -> Image { 
    // takes 2 seconds to download
}

2 - Asynchronous to protect state

You want to safely access mutable state or as Apple puts it:

you use await to mark the potential suspension point.

Actors help you with the 2nd kind of asynchronous operations.

actor EventStorage {
    var events: [String] = []
    
    func add(event: String) {
        events.append(event)
    }
}

let storage = EventStorage()
print(await storage.events) // if adds are happening when you're accessing `events` then you get suspended until the `add` finishes. Otherwise events property is immediately returned to you.

For more on that see docs from here.

So what’s special about @MainActor? Is it also an actor?

It’s an actor. But with the addition that it’s globally accessible from anywhere in your app. It’s a shared singleton. For more on that see Global actors in Swift - Swift with Majid

Based on docs, the executor of @MainActor is equivalent to the main dispatch queue. The main dispatch queue, and the MainActor share some common characteristic:

  • each are a single, unique instance, with DispatchQueue.main being a class variable and MainActor a singleton.
  • both are mostly invoked asynchronously.

Any difference between the two?

  • Actors and therefore @MainActor offer compile time checks if something isn’t properly awaited. It will clearly show you were your call site and destination context don’t match.
  • DispatchQueue at best offers run time checks.

Can I annotate an actor with @MainActor?

You can’t annotate an actor itself with @MainActor or another global actor. It’s because the compiler can’t decide if you want to isolated data to the global actor’s context or the actor’s. An actor’s data (its properties) is either nonisolated, or isolated to a single actor. It can’t ever be isolated to multiple actors, that just defeats the whole purpose of isolation. Examples:

@MainActor // ERROR: Actor 'Person' cannot have a global actor
actor Person {}

Annotating a function with a global actor depends:

actor Person {
    
    var age: Int = 0

    @MainActor // ALLOWED. Because the function isn't accessing data isolated to another actor.
    func log() {
        print("hi")
    }

    @MainActor 
    func increment() {
        age += 1 // ERROR Actor-isolated property 'age' can not be mutated from the main actor
    }
}

Can I opt of out an actor’s isolation?

Yes. There are two ways to do that:

  • Annotate properties / functions with another global actor. This is only allowed if there’s no shared data between the two actors. See the example above.
  • Annotate properties / functions with nonisolated.

At the end you can mix-and-match actors but only if all reads and writes against a property are isolated to a single actor.

Should I be using actors everywhere?

You don’t start with actors. Use them only if you must. So far I’ve only used them by using MainActor. I haven’t created my own yet.

A good use case is if you have to collect and combine multiple data streams into one, then using actors will help you significantly. Examples:

  1. You need to store events (logs) that happen in your app. Then send them to server at a later time. Events may need to simultaneously get stored because they can get triggered by:

    • user interaction
    • callbacks from network operations on the screen
    • background operations originated from elsewhere in the app (a timer, an OS event, push notification, device orientation change, location / bluetooth event)
  2. A chat app’s MessageManager object which needs to manage a screen messages, uploading media, making calls, adding stickers in realtime.

  3. If you need to download and store the media of a large list of rows in parallel. The rows are began in order, but won’t finish in order, and you may want to add a progress bar if each row is large (like a full episode).

To come at it from a different angle: if you’re protecting state using serial queues or barrier async then that’s a hint to use actors instead…

Jargon

Isolation

An actor has its own state and that state is isolated from the rest of the program + synchronized access to that data is ensured. If you’re accessing an actor, then an actor gets you isolated in a way that you won’t ever get a crash due to multi-threading access. Also as mentioned before the data isolation provided happens at compile-time.

Context

At any given time your either:

  • in the context of an actor
  • in a nonisolated context. (FYI Anything marked with @preconcurrency has no actor context.)

Global Actor

An actor that’s a singleton and accessible through out your app. You can just annotate types, functions with it just like how you do that with @MainActor.

Executor

An abstraction to run jobs.

Progressive disclosure

It just means that Apple is slowly and iteratively introducing new Swift Concurrency to your codebase. You can disclose a type, a function to Swift Concurrency or entire module without having to rid of completionHandlers and DispatchQueue all at once, you can do that over time. Though Swift 6 will mean less progressive disclosure and more of an ‘immediate disclosure’.

Boundary

Crossing a boundary: Moving values into or out of an isolation domain is known as crossing an isolation boundary. This is because actors have to communicate and coordinate data.

Cooperative Thread Pool

A pool of threads that play nice with the CPU. ‘Cooperative’ here means they place nice with the CPU and don’t choke it.

The new thread pool will only spawn as many threads as there are CPU cores, thereby making sure not to overcommit the system. Unlike GCD’s concurrent queues, which will spawn more threads when work items block, with Swift threads can always make forward progress. Therefore, the default runtime can be judicious about controlling how many threads are spawned. This lets us give your applications the concurrency you need while making sure to avoid the known pitfalls of excessive concurrency.

From WWDC 2021 - Swift concurrency: Behind the scenes. Emphasis mine.

Forward Progress

The Swift concurrency model is designed so that threads can efficiently handle both blocked and runnable tasks. When a Swift task needs to wait for something (like async I/O), it can yield control of its thread without blocking it completely. The thread can then be used to execute other ready tasks. This ability to switch between tasks on the same thread means that “forward progress” can always be made - the system can keep doing useful work without needing to create more threads!

Old vs New

Prior to Actors and Swift Concurrency we used to say: This block is executed / dispatched on to the main Queue. Now when using actors we say:

This task is isolated to the main actor

actor Person {
    var age = 10 // the age parameter is isolated to the `Person` actor instance.

    func increment() {  // the increment method is isolated to the `Person` actor instance.
        age += 1
    }
}

Acknowledgements and References

Shout to a fellow engineer who helped correct a part of this post.