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:
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:
- Discipline. Nothing stops a new method from reading
statuseswithout 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. - Blocking a thread.
queue.syncholds the calling thread until the queue has run the block. Chapter 1 showed how blocking ends in a pool of fixed size. - No reentry. If code already running on the queue calls
queue.syncon the same queue, the documentation forsyncsays 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. @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:
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
statusessynchronously. - From outside, you reach the actor’s state only with
await:await store.status(of: "nas"). Withoutawait, 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
letof aSendabletype can be read withoutawaitfrom code in the same module, because nothing can change it. From other modules it still takesawait, unless the property is markednonisolated. That keeps a library free to turn theletinto avarlater.
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.
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?”.
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:
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:
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:
- Call A goes in, finds
resultsempty and reachesawait ping. - At the
await, call A suspends and leaves the actor. The actor is free. - Call B goes in, still finds
resultsempty (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.
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:
- Keep critical sections free of
await. Whatever must happen atomically, checking and writing, do synchronously between twoawaits. The WWDC21 session “Protect mutable state with Swift actors” puts it as: mutate actor state in synchronous code. - Check again after an
await. Don’t rely on what you checked before it. - Record your intent before the
await. The classic move is to store the task itself while it’s in flight:
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.
@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))"
}
}@MainActoron a class isolates all its properties and methods to the main actor. You can also put it on single functions, properties and closures.textchanges after anawait, and the compiler guarantees that it happens on the main actor. Nomain.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@MainActorcode inherits that isolation. - The
asyncmain()of a@maintype 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 code | Time per call |
|---|---|
| into an ordinary actor | about 50 ns |
| onto the main actor | about 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:
- 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:Mutexfrom the Synchronization module (SE-0433, iOS 18 and macOS 15 or later), orOSAllocatedUnfairLockon older systems. - 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. - 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
| GCD | Swift | The difference |
|---|---|---|
| serial queue over state | actor | the compiler checks the protection |
queue.sync { read } | await actor.value | the task waits, the thread is free |
queue.async { write } | await actor.set(…) | writes from outside only through methods |
nested queue.sync | reentering the actor | GCD stops the program; the actor lets you in, the invariants are yours |
DispatchQueue.main.async | @MainActor, MainActor.run | the compiler sees the isolation |
dispatchPrecondition(.onQueue) | not needed | checked at compile time |
| Rule | |
|---|---|
| Inside the actor | synchronous |
| From outside | only with await; writes only through methods; let without await in the same module |
| Running inside at once | one stretch of code between awaits |
At an await | the actor is open to other calls (reentrancy) |
| Atomic | only within a stretch without await |
Try it yourself
- Write an actor
Counterwithvar value = 0and a methodbump() asyncthat readsvalueinto a local, doesawait Task.yield(), and writes back the old value plus one. - Before you run it, write down what
valuewill be after 100 concurrentbump()calls. - Run the 100 calls with
withTaskGroupand print the result. Run it a few times. - Remove
await Task.yield(). What changes? - 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
- Swift Evolution: SE-0306 actors, SE-0313
nonisolatedand isolation control, SE-0316 global actors, SE-0302Sendable, SE-0323 async main, SE-0433Mutex. - Apple documentation:
Actor,MainActor,GlobalActor,DispatchQueue.sync,OSAllocatedUnfairLock. - WWDC21: Protect mutable state with Swift actors, Swift concurrency: Behind the scenes.