You've probably written a race condition. It's okay. We all have. We deploy it, and we just kinda... hope. We're all that dog in the "This is fine" meme, surrounded by the fire of multiple threads trying to write to
var totalClicks = 0at the same time.
For decades, our solution was... "don't do that." We used locks, mutexes, semaphores, or the grand-daddy of all, DispatchQueue.sync. These are all just fancy ways of putting a velvet rope in front of our data, and they are so easy to get wrong.
Then Swift 5.5 gave us Actors.
TLDR
- An Actor is a special kind of
classthat protects its own data. It's an "island of isolation." - It prevents data races by forcing all access to its
varproperties from the outside to be asynchronous (usingawait). - It processes these
awaitcalls one at a time, in the order they arrive (mostly). It has a "mailbox," or a queue, for incoming jobs. - Crucially, an actor is NOT a thread. It's a lightweight task that runs on a cooperative thread pool managed by the Swift runtime.
- The "magic" isn't in the CPU; it's in the compiler (which enforces the
await) and the Swift Runtime (which manages the mailbox and thread pool). - Actors are re-entrant, which is a fancy way of saying they can pause their current job (at an
await) to run a new job, then come back later. This is weird, but it prevents deadlocks.
The Wild West of Dispatch Queue
Before actors, if you wanted to protect a variable, you did this:
class ClickCounter {
private var count = 0
private let queue = DispatchQueue(label: "com.my-app.click-counter-queue")
func increment() {
queue.sync {
// This block is "safe"
self.count += 1
}
}
func getCount() -> Int {
return queue.sync {
// This block is also "safe"
return self.count
}
}
}
This... works. But it's cursed.
- It's manual: You have to remember to wrap every access in
queue.sync. If you forget just one, poof, data race. - It's blocking:
queue.syncblocks the calling thread until the work is done. If you call this from the main thread, your UI freezes. - It's easy to deadlock: What if
queue.synccalls another function that also tries toqueue.syncon the same queue? You've just deadlocked. Your app is frozen forever. Congrats.
Thread 1 (Main) Thread 2 (Background)
| |
|--- accesses var C ---X--- accesses var C ---|
| | (kaboom)
| (UI frozen) | (Wrong value)
v v
We needed something better. We needed the compiler to protect us from ourselves.
What Even Is an Actor?
This is what an actor looks like:
actor ClickCounter {
private var count = 0
func increment() {
// Look ma, no queue!
self.count += 1
}
func getCount() -> Int {
// Also no queue!
return self.count
}
}
This... this is just a class, right? It looks so simple. This was one of those "psyching myself out" moments. I expected more.
But the magic isn't in what you write. It's in what the compiler makes everyone else write.
If you have an instance of it: let counter = ClickCounter()
-
counter.increment()-> COMPILE ERROR.(Expression is 'async' but is not marked with 'await') -
let current = counter.count-> COMPILE ERROR.(Actor-isolated property 'count' can only be accessed 'async')
To use it, you must await:
func doAThing() async {
await counter.increment()
let current = await counter.getCount()
print(current)
}
The actor keyword told the compiler: "Hey. See this count variable? And increment()? They are isolated. Anyone who wants to touch them from the outside? Make them get in line. Make them await."
It's Just a Class (With a Bouncer)
This is the best analogy. An actor is a nightclub.
- Its properties (
var count) and methods (increment()) are inside the club. - The actor itself is the only thread (metaphorically) allowed inside at a time.
- All those
awaitcalls from other tasks are people lining up outside. - The actor has a Mailbox (a queue) which is the line.
- The Swift Runtime is the Bouncer
(⌐■_■). It picks the next person from the line, lets them in, waits for them to finish, and kicks them out before letting the next person in.
(The "Mailbox" Queue)
[Task 3] [Task 2] [Task 1] <--- Bouncer (Swift Runtime)
|
v
+--------------+
| Actor |
| (Nightclub) |
| |
| var count = 0| <-- "Safe" State
| |
+--------------+
When Task 1 (await counter.increment()) runs, the bouncer lets it in. It has exclusive access. It messes with count, finishes, and leaves. Then the bouncer lets Task 2 in.
No two tasks are ever inside at the same time. No data races. Beautiful.
Memory & CPU: The Great Actor Misdirection
This is the next psych-out moment.
"Okay, so an actor is a thread, right? It's got its own serial queue." NOPE.
This is the big lie. An actor is not a thread. It does not have its own DispatchQueue.
Where does an actor live?
In memory, it's just a class. It's a reference type. It lives on the heap. It's a chunk of memory that holds its properties (like count) and some internal bookkeeping stuff (like a lock and its mailbox).
Where does an actor run?
This is the crazy part. It doesn't "run." Its jobs run.
Swift has a Cooperative Thread Pool. When your app launches, the Swift Runtime creates a handful of threads, maybe as many as you have CPU cores. Let's say 8.
Your app might have thousands of async tasks (including all your actor calls). The Swift Runtime (our Bouncer, also called the Executor) is the big cheese. It juggles all these thousands of tasks onto those 8 available threads.
Your Tasks (1000s of them)
[T1] [T2] [T3] [T4] ... [T999]
\ | / /
\ | / /
(Swift Runtime Executor)
| | | |
v v v v
+--+--+--+--+--+--+--+--+
| T1| T4| |T99| |T23| ... (CPU Cores / Thread Pool)
+--+--+--+--+--+--+--+--+
When you await counter.increment(), you aren't "blocking a thread." You're just creating a little job, handing it to the actor's mailbox, and your current task suspends. It says to the Runtime, "Hey, wake me up when this is done," and gets off the thread.
The Runtime is now free to run a different task on that thread. Maybe it's UI work. Maybe it's another actor.
Eventually, the increment() job gets to the front of the line. The Runtime says, "Okay, you're up," and slaps that job onto any available thread from the pool. It runs, finishes, and the original task gets woken up.
This is so much more efficient than DispatchQueue.sync, which just sits there, holding a whole thread hostage, doing nothing.
Down to the Metal: What the Assembly Actually Says
So what does this look like in assembly? This is where it gets really cursed.
Let's look at three cases.
Case 1: nonisolated (The Simple One)
If you have nonisolated let id = "counter", it's immutable. It's safe. The compiler knows this. Accessing it is just a normal, direct memory read.
; Just reading a 'let' property.
; rdi holds 'self' (the actor instance)
mov rax, [rdi + 0x18] ; Move value at offset 0x18 (the 'id' property) into rax
; ... (no locks, no queues, no magic)
It's fast. It's just a regular class property read.
Case 2: Internal Function (Already Inside the Club)
What about when increment() (which is private) accesses self.count? It's already inside the club. It doesn't need to get in line. It's already been "de-queued" by the bouncer.
; Inside 'increment()', accessing 'self.count'
; rdi holds 'self'
mov rbx, [rdi + 0x10] ; Move 'count' (at offset 0x10) into rbx
add rbx, 1 ; Add 1 to it
mov [rdi + 0x10], rbx ; Move it back
; ... (still no magic, just raw, fast access)
This is the "psych out" again. Inside the actor, all access is synchronous and fast. It's just simple assembly.
Case 3: External await Call (Getting in Line)
This is the one. What does await counter.increment() actually compile to?
You might expect a JMP or CALL to the increment function. You would be wrong.
You don't call increment at all. The compiler rewrites your code. It breaks your function into pieces (a "continuation") and tells the Swift Runtime to enqueue the piece of code that calls increment.
The assembly for await counter.increment() looks less like CALL and more like this (conceptually):
; This is a WILD simplification
; rdi = the actor instance ('counter')
; rsi = the job to run (a function pointer to 'increment')
; ... (stuff to package up 'self')
CALL swift_actor_enqueue ; <-- THE MAGIC BOUNCER
; ...
CALL swift_task_suspend ; <-- "I'm going to sleep now, wake me later"
; ...
; (code resumes here, hours later, maybe on a different thread)
That's it. That's the whole trick.
It's not "assembly" in the way we think of it. It's the compiler generating calls into the pre-built, C++-based Swift Runtime. The swift_actor_enqueue function is the bouncer. It takes the job, adds it to the actor's internal mailbox (with a lock, of course), and returns. Then swift_task_suspend parks your current task.
The complexity isn't in your code's assembly. The complexity is hidden inside the runtime. It's an abstraction, and a damn good one.
Re-entrancy: The Part That Melts Your Brain
Here's the last rabbit hole. And it's a doozy.
What happens if an actor awaits another async function?
actor BankAccount {
var balance = 100
func withdraw(amount: Int) async -> Bool {
if balance < amount { return false } // Check 1
// Uh oh...
let permission = await fraudCheckService.requestPermission(amount)
// We're back! ...or are we?
if !permission { return false }
// Check 2
if balance < amount {
print("Wait, what? I already checked this!")
return false
}
balance -= amount
return true
}
}
When the code hits await fraudCheckService..., it suspends.
But what does that mean?
It means the actor gives up its lock. The bouncer says "Okay, you're waiting for the network, get out." And the bouncer immediately goes back to the mailbox and says, "NEXT!"
(Inside withdraw())
|
v
await fraudCheck...
|
+-----> (Task suspends, leaves the actor)
|
(Mailbox) [Task 2: another call to withdraw(75)]
|
v
(Bouncer lets Task 2 in, while Task 1 is suspended)
|
v
(Task 2 runs. balance is 100. amount is 75. Checks pass.
balance becomes 25. Task 2 finishes.)
|
v
(Task 1's network call *finally* returns.
It gets back in line at the mailbox.)
|
v
(Bouncer lets Task 1 back in. It resumes.)
|
v
if balance < amount... (balance is 25, amount is 100)
|
v
(Check 2 fails. Thank god.)
This is re-entrancy. The actor "re-enters" itself to run other jobs while one is paused. This is wildly different from a DispatchQueue.sync block, which would never let go.
This is why we had to check the balance twice. Between the await, our actor's state could have been completely changed by other tasks.
This is the "beautifully cursed" part. It prevents deadlocks (because no one ever "holds" the lock while waiting), but it forces you to be very careful about your state before and after every single await.
Lessons Learned
- Actors are abstractions, not threads. The "magic" is 90% the Swift compiler and 10% the Swift Runtime.
-
The compiler is your friend. It's what inserts the
async/awaitbarrier, which is the real protection. - Access inside an actor is fast. It's just raw, synchronous property access. No locks, no queues.
-
Access outside an actor is a runtime call. It's
swift_actor_enqueueandswift_task_suspend. It's a state machine, not a simpleCALL. -
awaitmeans "pause and let someone else cut in." This is re-entrancy. Always re-check your state after anawait.
Next Steps
This is just the start. We didn't even talk about MainActor (a special, global actor that is tied to the main thread) or Sendable (a protocol that tells the compiler which types are safe to throw over the wall into an actor).
(\_/)
(O.o)
(")_(")
(Down the next rabbit hole...)
But now, when you type actor, you know what you're really doing. You're not just making a class. You're building a tiny, isolated island, hiring a bouncer, and telling the compiler to manage the line-up. And that's pretty cool.
Top comments (0)