Chapter 2 Swift concurrency for GCD hands

Actors for GCD hands: isolation, reentrancy and the main actor

What replaces the serial queue, why an actor lets other calls in while it waits, and what @MainActor gives you that main.async never did.

Chapter 1 was about running work concurrently. This chapter is about the question that always follows: what do you do with shared mutable state that several tasks touch at once? With GCD the answer was a serial queue. Swift has a replacement, and it isn’t a one-to-one swap.

The experiments here ran the same way as in chapter 1: on a Mac, in Swift 5 language mode with -strict-concurrency=complete.

The GCD starting point: state behind a serial queue

Say we keep the latest status of a few servers. The UI and notifications read it, and checks running in parallel write it. With GCD you protect it like this:

Swift
final class GCDStatusStore: @unchecked Sendable {
    private let queue = DispatchQueue(label: "status-store")
    private var statuses: [String: Status] = [:]

    func status(of host: String) -> Status? { queue.sync { statuses[host] } }
    func set(_ status: Status, for host: String) { queue.async { self.statuses[host] = status } }
}

Every access to statuses goes through one queue, so two threads never touch the dictionary at the same time. It works, but look at what it rests on:

  1. Discipline. Nothing stops a new method from reading statuses without going through the queue. The compiler stays quiet, and the race shows up a month later. dispatchPrecondition(condition: .onQueue(queue)) catches it, but only at run time and only where you remembered to add it.
  2. Blocking a thread. queue.sync holds the calling thread until the queue has run the block. Chapter 1 showed how blocking ends in a pool of fixed size.
  3. No reentry. If code already running on the queue calls queue.sync on the same queue, the documentation for sync says it deadlocks. In practice libdispatch detects it and stops the process with “dispatch_sync called on queue already owned by current thread”. Such calls hide in chains of methods and are hard to find.
  4. @unchecked Sendable. With it we tell the compiler “trust me, this class is thread-safe”. It trusts us and checks nothing more.

The common root: the protection is an agreement that only the author knows about. The compiler doesn’t see any link between statuses and queue.

A type that protects itself

The same store as an actor:

Swift
actor StatusStore {
    let name: String
    private var statuses: [String: Status] = [:]

    init(name: String) { self.name = name }

    func status(of host: String) -> Status? { statuses[host] }
    func set(_ status: Status, for host: String) { statuses[host] = status }
}

No queue, no sync, no async. An actor is a reference type, like a class, with one difference: the language itself protects its mutable state (SE-0306). The rules:

  • Inside the actor, everything works as in an ordinary class. Its methods read and write statuses synchronously.
  • From outside, you reach the actor’s state only with await: await store.status(of: "nas"). Without await, the code doesn’t compile.
  • You can’t write an actor’s property from outside at all, not even with await. Changes go through the actor’s methods, so the actor stays in control of its invariants.
  • An immutable let of a Sendable type can be read without await from code in the same module, because nothing can change it. From other modules it still takes await, unless the property is marked nonisolated. That keeps a library free to turn the let into a var later.

Compare with the serial queue: points 1, 3 and 4 are gone. You can’t forget the protection, there is no queue.sync to nest, and there’s no “trust me”. Point 2 is next.

One call at a time, and how the others wait

An actor runs at most one piece of its code at a time. SE-0306 describes calls from outside as messages in the actor’s mailbox, processed one at a time. If two tasks call methods on the same actor, one goes in and the other waits.

That sounds like a serial queue, and the key difference is how the others wait. queue.sync blocks the calling thread. A task that waits for an actor suspends at its await, as in chapter 1, and its thread goes off to run other work.

Both run one thing at a time. The difference is how the caller waits: queue.sync holds its thread, while a task waiting for an actor suspends and gives the thread back to the pool.

Where does an actor’s code run? An actor has no thread of its own. Apple’s documentation for Actor: by default, actors execute tasks on a shared global concurrency thread pool, the same pool as in chapter 1. In my test, an actor method ran off the main thread. An actor isn’t a thread and isn’t a queue; it is a rule of access: one at a time.

Does the rule hold? In my test, 1000 concurrent tasks called an actor method bump() that does n += 1, with no await inside. The result was exactly 1000 every time. The same counter on a plain class gave 991, 976, 986, 945 and 1000 in five runs: a race that sometimes looks fine, which is how it slips through. The compiler only accepted the class version with @unchecked Sendable, our “trust me”.

The order in which waiting calls get in is not the order they arrived. SE-0306 is explicit: unlike a serial DispatchQueue, which is strictly first in, first out, the runtime considers a task’s priority when it picks the next one to run on the actor. In my test the actor was busy while four low-priority tasks arrived and then four high-priority ones; in all three runs the high-priority tasks went in first. If you need strict FIFO, an actor is the wrong tool.

Isolation: islands and bridges

Time to name what an actor does. An actor’s state is isolated: only code running on that actor can touch it. The methods of an actor are isolated to it; code outside isn’t.

A useful picture is a program made of islands of isolation:

  • every actor instance is an island of its own;
  • the main actor is one shared island for the UI;
  • code that belongs to no actor is nonisolated: free functions, structs, parsers.

Inside an island, calls are synchronous. Crossing to another island always takes await, because you may have to wait until there is room on the other side. This is what chapter 1’s compiler hint meant: “which thread am I on?” turns into “which island am I on?”.

Inside an island, calls are synchronous. Crossing to another island, or into one from nonisolated code, always takes await, and only Sendable values travel across.

A member of an actor can be marked nonisolated if it doesn’t touch mutable state (SE-0313). Then it can be called from outside without await:

Swift
extension StatusStore {
    nonisolated var title: String { "Store \(name)" }  // uses only the immutable `let`
}

The compiler holds you to it: a nonisolated property that reads statuses doesn’t compile.

What may cross between islands? Only values that are safe to hand to another domain: Sendable values (SE-0302). Status in the examples is a Sendable enum. Sendability deserves a chapter of its own.

Reentrancy: at an await, the actor lets the next call in

This is the most important part of the chapter, and the least expected if you think in GCD.

Let’s add a checker that remembers results and doesn’t ping a host twice:

Swift
actor NaiveChecker {
    private var results: [String: Status] = [:]

    func check(_ host: String) async -> Status {
        if let known = results[host] { return known }
        let status = await ping(host)       // the actor is free while we wait
        results[host] = status
        return status
    }
}

It looks safe: the actor lets calls in one at a time. Yet two concurrent check("nas") calls sent two pings in my test. Here is what happened:

  1. Call A goes in, finds results empty and reaches await ping.
  2. At the await, call A suspends and leaves the actor. The actor is free.
  3. Call B goes in, still finds results empty (A hasn’t written yet), and pings too.

The one-at-a-time rule wasn’t broken: at every moment, only one piece of code ran inside. But that piece is the stretch between two awaits, not the whole method. This is reentrancy: while a method waits, other calls can enter the actor.

The actor runs one stretch at a time (the top row), but a stretch ends at every await. In the naive cache B gets in while A is waiting, misses too, and pings again. Recording the task before the first await lets B join the ping that is already running.

Why is it built this way? The alternative is to keep the actor closed for the whole method, waiting included. SE-0306 lists what that would cost:

  • Deadlocks. Actor A waits for actor B, and B calls back into A: both wait forever. No thread has to block for this to happen.
  • Throughput. A closed actor keeps every caller waiting while one of its methods waits for the network.
  • Scheduling. A reentrant actor can let higher-priority work in while a lower-priority call is suspended.

Swift chose “never hang”, and the price is that invariants don’t survive an await. It’s the same lesson as in chapter 1: while a function is suspended, the world can change.

How to live with it:

  1. Keep critical sections free of await. Whatever must happen atomically, checking and writing, do synchronously between two awaits. The WWDC21 session “Protect mutable state with Swift actors” puts it as: mutate actor state in synchronous code.
  2. Check again after an await. Don’t rely on what you checked before it.
  3. Record your intent before the await. The classic move is to store the task itself while it’s in flight:
Swift
actor Checker {
    private enum Entry { case done(Status), inFlight(Task<Status, Never>) }
    private var entries: [String: Entry] = [:]

    func check(_ host: String) async -> Status {
        switch entries[host] {
        case .done(let status)?: return status
        case .inFlight(let task)?: return await task.value   // join the running check
        case nil:
            let task = Task { await ping(host) }
            entries[host] = .inFlight(task)                   // recorded before the first await
            let status = await task.value
            entries[host] = .done(status)
            return status
        }
    }
}

Now call B finds .inFlight and waits for the same task: one ping in my test. It works because writing .inFlight and checking for it happen in the same stretch, with no await between them.

Compare GCD: a serial queue with sync isn’t reentrant, and entering the same queue again stops the program. An actor is reentrant: it neither crashes nor hangs, but every await is a place where your invariants are your responsibility.

The main actor: an island for the UI

On Apple platforms the UI may only be touched from the main thread. With GCD that meant DispatchQueue.main.async { }, and discipline again. Swift has MainActor, which Apple’s documentation describes as a singleton actor whose executor is equivalent to the main dispatch queue. It is a global actor: there is one for the whole program, and you attach code to it with an attribute.

Swift
@MainActor final class DashboardModel {
    var text = ""
    let store: StatusStore
    init(store: StatusStore) { self.store = store }

    func refresh(host: String) async {
        let status = await store.status(of: host)   // hop to the store's island and back
        text = "\(host): \(String(describing: status))"
    }
}
  • @MainActor on a class isolates all its properties and methods to the main actor. You can also put it on single functions, properties and closures.
  • text changes after an await, and the compiler guarantees that it happens on the main actor. No main.async.
  • Nonisolated code can call these methods only with await.
  • To run a piece of code on the main actor once: await MainActor.run { … }.
  • From chapter 1: a Task { } created in @MainActor code inherits that isolation.
  • The async main() of a @main type runs on the main actor (SE-0323).

Every await to the main actor and back is a crossing between islands, and crossings don’t all cost the same. My measurement, in a -O build, numbers approximate:

Hop from background codeTime per call
into an ordinary actorabout 50 ns
onto the main actorabout 7 µs, roughly 130 times more

The main actor is tied to the main thread, so getting there means a real switch. Don’t hop onto the main actor once per iteration of a loop. Build the result in the background and update the UI once.

Your own global actors

MainActor is one case of a global actor. You can declare your own: a type marked @globalActor with a shared instance (SE-0316). You can then use it as an attribute on any declaration, and access to everything marked with it is synchronized through that one shared actor. It’s rarely needed; the typical case is several types that must share one lock.

When an actor is the wrong tool

An actor fits when its state is reached from async code. It doesn’t fit in three cases:

  1. Synchronous access from synchronous code. You can’t reach an actor without await, so a synchronous function can’t use one at all. Use a lock: Mutex from the Synchronization module (SE-0433, iOS 18 and macOS 15 or later), or OSAllocatedUnfairLock on older systems.
  2. Strict first-in, first-out order. An actor doesn’t promise it. Use a serial queue, or a single task that takes events one by one from an AsyncStream.
  3. Changing several actors atomically. You can’t hold two actors at once, and there are no transactions across islands. Design so that state that must change together lives in one actor.

Cheat sheet

GCDSwiftThe difference
serial queue over stateactorthe compiler checks the protection
queue.sync { read }await actor.valuethe task waits, the thread is free
queue.async { write }await actor.set(…)writes from outside only through methods
nested queue.syncreentering the actorGCD stops the program; the actor lets you in, the invariants are yours
DispatchQueue.main.async@MainActor, MainActor.runthe compiler sees the isolation
dispatchPrecondition(.onQueue)not neededchecked at compile time
Rule
Inside the actorsynchronous
From outsideonly with await; writes only through methods; let without await in the same module
Running inside at onceone stretch of code between awaits
At an awaitthe actor is open to other calls (reentrancy)
Atomiconly within a stretch without await

Try it yourself

  1. Write an actor Counter with var value = 0 and a method bump() async that reads value into a local, does await Task.yield(), and writes back the old value plus one.
  2. Before you run it, write down what value will be after 100 concurrent bump() calls.
  3. Run the 100 calls with withTaskGroup and print the result. Run it a few times.
  4. Remove await Task.yield(). What changes?
  5. Fix the first version without removing yield.

Task.yield() is a suspension point that simply gives up control for a moment.

What happens in the exercise, and how do you fix it?

With yield in the middle, the result is usually well below 100 and changes from run to run. At the yield each call leaves the actor, so many calls read the same old value before any of them writes it back, and their updates overwrite each other. Without yield there is no suspension between the read and the write, so each bump() runs as one stretch and the result is exactly 100. To fix the first version, keep the read and the write together with no await between them: yield first, then do value += 1.

Check yourself

Try to answer in your own words before you open each answer.

What is the difference between “one piece of code at a time runs inside an actor” and “an actor method runs from start to finish without interruption”?

The first is true, the second isn’t. The piece of code that runs alone is the stretch between two awaits. A method that contains an await can be suspended there, and other calls can run on the actor before it resumes. Only a method with no await in it runs without interruption.

Why can’t you write an actor’s property from outside, even with await, while reading is allowed?

Writes go through the actor’s methods so the actor stays in control of its invariants. SE-0306 also points out that a read-modify-write from outside would have a suspension point between the get and the set, which brings back exactly the race the actor was meant to prevent. A read is a single access, so with await it’s safe.

In DashboardModel.refresh there is an await between reading the status and writing text. Can text end up out of date? When?

Yes. The store can receive a newer status right after we read it. And on the main actor itself, two refresh calls can interleave: the first one suspends at its await, the second one finishes and writes text, and then the first resumes and overwrites it with its older status. Re-check after the await, or tag requests so that only the latest one writes.

Why did Swift make actors reentrant, even though it breaks invariants across await?

A non-reentrant actor stays closed while its method waits. Two actors that call each other would then wait for each other forever, every caller would be stuck behind a method that waits for the network, and higher-priority work couldn’t get in. Swift chose to never deadlock in this way, and in exchange you check your assumptions after every await.

You need a thread-safe cache that is read from a synchronous getter. Is an actor the right choice?

No. An actor can only be reached with await, and a synchronous getter can’t await. Use a lock: Mutex from the Synchronization module on iOS 18 and macOS 15 or later, or OSAllocatedUnfairLock on older systems.

Sources