The question this answers
What do you get by making a piece of state single-threaded by construction, and what does the mailbox cost you?
A multiplayer game server holding 20 000 live game sessions. Each session has a board, a turn counter and two connected players. Moves arrive from either player at any time.
Between actors: nothing. Each session's state is reachable only from its own actor. The mailbox is shared between senders and the actor, and it is the only synchronized structure in the design.
A session's turn counter advances by exactly one per accepted move, and no two moves are ever applied to the same session concurrently.
Synchronization exists to preserve this sentence. If a schedule can make it false, the code is wrong no matter which primitive it uses.
Sequential inside, concurrent outside
An actor is three things: state nothing else can reach, a mailbox that anyone can send to, and a loop that takes one message at a time and processes it to completion before taking the next. That last clause is the whole design. Because the loop is sequential, the code inside an actor is ordinary single-threaded code — no locks, no atomics, no memory-ordering questions, no critical sections to find (Finding the Critical Section simply does not apply).
The concurrency lives *between* actors. 20 000 sessions is 20 000 actors, each sequential, all running concurrently and — if the runtime schedules them over a thread pool — genuinely in parallel across cores. You get parallelism proportional to the number of independent state units, which for this workload is exactly the right axis, because sessions are naturally independent.
Note what has been achieved: the check-then-act problem in the turn counter, which under shared state would need a lock around read-modify-write, is now impossible by construction. Two moves for the same session are two messages in one mailbox, and the actor processes them one after the other. The invariant is preserved not by a protocol but by the shape (Reasoning About Races: A Method, Not an Instinct has nothing to enumerate here).
The ordering guarantee is narrower than people assume
Almost every actor system guarantees this and only this: messages from one sender to one receiver are processed in the order they were sent. That is a per-pair FIFO guarantee, and it is genuinely useful. What it does not give you is any relationship between messages from *different* senders, or any relationship between messages sent to *different* actors.
The consequence bites in exactly the place people do not look. Actor A sends Debit to the account actor and then sends Notify to the email actor. There is no guarantee the account actor processes the debit before the email actor processes the notification — they are independent actors on independent schedules. The email can go out describing a state that has not been applied yet. Under shared state with one lock this could not happen; under actors it is the default, and it is a correctness bug that reads like a race even though there is no race (Ordering Guarantees: Four Levels, Four Prices).
The schedule below shows the version that surprises people most: two senders, one receiver, with a per-pair guarantee that holds perfectly while the *application-level* invariant still breaks — because "process one at a time" removes concurrent mutation, not causal ordering between senders.
| # | Player A gateway | Player B gateway | Session actor (7742) | Notification actor | State |
|---|---|---|---|---|---|
| 1 | send Move(d4) to session | · | · | · | mailbox(7742)=[Move-A] turn=41 whose=A |
| 2 | · | send Move(e5) to session | · | · | mailbox(7742)=[Move-A, Move-B] turn=41 whose=A |
| 3 | · | · | process Move(d4): legal, apply | · | turn=42 whose=B mailbox(7742)=[Move-B] |
| 4 | · | · | send BoardUpdate to notification actor | · | mailbox(notif)=[Update-42] |
| 5 | · | · | process Move(e5): legal now, apply | · | turn=43 whose=A mailbox(7742)=[] |
| 6 | · | · | send BoardUpdate to notification actor | · | mailbox(notif)=[Update-42, Update-43] |
| 7 | · | · | · | notification actor is backlogged; processes Update-43 from a second worker | sent to clients=turn 43 ✕ A single actor per mailbox was assumed. If the notification "actor" is a pool sharing one mailbox, per-pair FIFO no longer implies in-order processing, and clients receive turn 43 before turn 42. |
| 8 | · | · | · | process Update-42 | sent to clients=turn 42 after turn 43 ✕ Clients render a board that moved backwards. No data race occurred; the ordering assumption was never guaranteed. |
What it costs, and the line where it stops being a concurrency problem
Per message you pay an enqueue, possibly a scheduler wake, a dequeue, and — if the runtime copies messages between actors — a serialization or clone. For 20 000 sessions receiving a move every few seconds that is nothing. For a hot loop doing a million operations a second on one piece of state, an actor is a single-threaded bottleneck with queueing overhead bolted on, and a mutex around a struct is both simpler and faster (Mutexes: What They Protect and What They Do Not).
The second cost is that every mailbox is a queue, so every mailbox has the capacity question from Bounded vs Unbounded Queues. Many actor runtimes default to unbounded mailboxes, which is convenient right up to the point where one slow actor accumulates a million messages and the process dies. A bounded mailbox means send can fail or block, which propagates backpressure and is almost always the right choice (Backpressure).
The third cost is the important one for architecture. Actors are location-transparent by design — the same send works whether the target is in this process or on another machine — and that is presented as a feature. It is also the moment the problem changes category. A local send cannot be lost; a remote one can. A local actor either exists or does not; a remote one can be unreachable, or reachable but restarted with different state. Location transparency makes the *syntax* uniform and the *semantics* completely different, and that difference is where distributed-systems reasoning starts.
| Concern | Mutex over shared struct | Actor | Message passing with snapshots |
|---|---|---|---|
| Concurrent mutation | Prevented by the lock, if every site takes it | Impossible by construction | Impossible — one owner mutates |
| Cost per operation | Lock acquire/release | Enqueue + schedule + dequeue | Enqueue + snapshot cost |
| Scales to many independent units | One lock per unit; lock bookkeeping grows | Natural — one actor per unit | One owner per unit |
| Reads | Direct, cheap, current | A request message and a reply — expensive | Free from a local snapshot, but stale |
| Ordering across units | Global, if a single lock covers them | None — must be carried explicitly | None |
| Failure isolation | An exception can leave the struct inconsistent | Supervisor restarts the actor with fresh state | Owner crash loses the state |
| Backpressure | Blocking on the lock | Only with a bounded mailbox | Only with a bounded queue |
| Becomes distributed | Never — a local mutex means nothing remotely | Silently, via location transparency | Explicitly, when you choose a broker |
Key points
- An actor is private state plus a mailbox plus a strictly sequential loop; the code inside needs no synchronization because there is no concurrency inside.
- Parallelism comes from having many actors, so the model fits workloads with many independent state units and fits a single hot object badly.
- The ordering guarantee is per sender/receiver pair only. Nothing orders messages from different senders or across different actors.
- A "logical actor" implemented as a pool draining one mailbox has thrown away the sequential guarantee that made it an actor.
- Unbounded mailboxes are the default in several runtimes and are the same OOM path as any other unbounded queue.
- Location transparency keeps the syntax identical while changing the semantics completely — that is where this stops being a concurrency problem.
The loop, answered
Every field is required, which is why no lesson here can recommend concurrency without naming the interleaving that breaks it, the complexity it adds, and the simpler thing to consider first.
- • Each actor owns state that no other actor holds a reference to; the only public surface is its address.
- • A send appends a message to the target's mailbox and returns immediately — it does not wait for processing and carries no result.
- • The runtime schedules a ready actor onto a worker thread, runs its loop for one message (or a bounded batch), then unschedules it so other actors get the thread.
- • The actor may, while handling a message, change its own state, send to other actors, or spawn children — never touch another actor's state.
- • A supervisor watches for actor failure and applies a policy: restart with fresh state, restart with saved state, stop, or escalate.
- • Request/response is built on top by including a reply address in the message; the runtime does not provide it as a primitive.
- • Two moves for one session arrive simultaneously from two players: both land in one mailbox and are processed strictly one after the other. The read-modify-write of the turn counter cannot interleave — the shape prevents it.
- • Sender A sends M1 then M2 to actor X: X processes M1 then M2. Per-pair FIFO holds.
- • Sender A sends M1 to X, sender B sends M2 to X, A sent first in wall-clock time: X may process M2 first. No guarantee exists between different senders.
- • Actor X sends to Y and then to Z: Y and Z process on independent schedules, so an effect visible via Z can precede the effect via Y. This is the cross-actor ordering trap.
- • Request/response deadlock: X sends a request to Y and blocks its loop waiting for the reply; Y sends a request to X and blocks. Both mailboxes fill; neither loop advances. A circular wait with no locks anywhere (The Four Conditions).
- • Supervisor restart mid-conversation: X crashes after receiving a request and before replying; the supervisor restarts it with fresh state; the caller waits forever unless it had a timeout (Timeouts).
- • An actor guarantees its own state is never mutated concurrently, and that its message handler runs to completion before the next message starts.
- • It guarantees per-pair FIFO ordering between one sender and one receiver.
- • It does NOT guarantee ordering between different senders, or between messages routed through different actors.
- • It does NOT guarantee delivery in the local case if the mailbox is bounded and full, and does not guarantee it at all in the remote case.
- • It does NOT guarantee the message was processed, or processed successfully. Send is fire-and-forget; without an explicit reply and timeout the sender learns nothing (Orphaned Tasks).
- • A supervisor guarantees the actor is restarted. It does NOT guarantee the in-flight message is retried, or that partially applied effects are undone.
- • The mailbox is the only contended structure, and it is contended between senders — high fan-in to one actor is a single-lock bottleneck like any queue.
- • A hot actor is a hard single-core ceiling: no amount of hardware parallelism helps one actor, because its loop is sequential by definition.
- • The scheduler itself contends: mapping 20 000 actors onto N threads means a run queue, and work-stealing to keep threads busy (Work Stealing).
- • Request/response doubles mailbox traffic and adds a wait, converting an actor from a throughput unit into a latency unit.
- • Mailbox growth without bound when an actor is slower than its senders — the standard OOM path.
- • Cross-actor ordering bugs: effects observed in an order the code never intended, with no race and no lock to blame.
- • Deadlock via request/response cycles between actors that block their loops.
- • Lost work on supervisor restart: the message being handled when the actor crashed is gone unless it was persisted.
- • Silent failure: a send to a stopped or nonexistent actor is frequently a no-op, so a broken pipeline looks like an idle one.
- • Hot-actor starvation: one actor with a huge backlog monopolises a worker thread if the runtime does not bound the batch it processes per schedule.
- • When the state partitions naturally into many independent units — sessions, connections, devices, documents, accounts — because that is exactly the axis the model parallelises on.
- • When each unit has a multi-step invariant that would otherwise need a lock held across several fields; sequential processing makes it free.
- • When failure isolation matters: a supervisor restarting one session actor is a far smaller blast radius than an exception corrupting a shared structure (Reliability Patterns in Architecture).
- • When the messages are already the natural unit of work — a protocol handler receiving discrete commands is an actor whether or not you call it one.
- • When there is one hot object and no partition. An actor makes it a single-threaded bottleneck plus queueing overhead; a mutex is simpler and faster.
- • When reads dominate. Every read becomes a request/response round trip, where a read/write lock or an immutable snapshot would be nearly free (Read/Write Locks, Honestly, Immutability as a Concurrency Strategy).
- • When operations must span several units atomically — cross-actor transactions are not something the model provides, and building them means reinventing two-phase commit.
- • When the team treats location transparency as free and ships local-actor assumptions onto a network (A Mutex on Server A Does Nothing About Server B makes the same point about mutexes).
- • Mailbox depth and age per actor, with a top-N view — the single most useful actor-system metric, and the one that catches the hot actor before it OOMs.
- • Message processing time distribution per actor type; a long tail means one handler is blocking a worker thread that other actors need.
- • Actor restart counts by supervisor — a rising restart rate is a crash loop that the supervision policy is successfully hiding.
- • Scheduler thread utilisation against the number of runnable actors: many runnable actors and idle threads means the scheduler or a blocking handler is the problem.
- • Reply timeout rate for request/response pairs, which is the only way a fire-and-forget send ever tells you it failed.
- • You adopt a runtime and its scheduling, supervision and mailbox semantics — a large conceptual surface, and one that behaves differently between implementations.
- • Debugging changes shape: stack traces stop at the actor loop, so causality has to be reconstructed from message traces and correlation ids (Correlation IDs: Turning Lines Into a Story, Carrying the Trace Across the Gap in Performance).
- • Any operation spanning several actors needs an explicit protocol — a saga, a coordinator, a sequence number — none of which the model gives you (Saga Pattern in Architecture).
- • Testing requires driving the scheduler, because the interesting behaviour is ordering across actors and that is not deterministic by default (Deterministic Replay: Making the Schedule Reproducible, Stress Testing: A Test That Passed Once Proves Nothing).
- • A mutex around the struct, when there is one unit of state and the operations are short. Fewer moving parts, no runtime, no mailbox to bound (Mutexes: What They Protect and What They Do Not).
- • Message passing with an owning task and snapshots, when readers outnumber writers and staleness is acceptable — the same isolation without request/response for reads (Message Passing).
- • A partitioned worker pool keyed by session id: the same "one unit, one sequential processor" property using a plain thread pool and a hash, with far less machinery.
- • A database row with optimistic concurrency, when the state must survive a restart anyway — durability and isolation in one mechanism (Optimistic Concurrency Control).
What people believe, and what is true
Actors eliminate race conditions.
They eliminate concurrent mutation of one actor's state. Ordering races across actors are wide open, and they are the ones that survive to production because there is no lock and no detector to catch them.
Actors eliminate deadlock.
They eliminate lock-ordering deadlock and introduce reply-cycle deadlock, which has no lock table to dump and usually presents as "everything is slow".
Location transparency means I can distribute later for free.
The call syntax is identical and the failure model is not. A remote send can be lost, duplicated or arbitrarily delayed, and the actor on the other end may have restarted with different state.