Queues, Channels & Message Passing

The Actor Model

Private state, a mailbox, and strictly sequential message processing. Inside an actor there is no concurrency at all, which is why there are no locks — and the costs are message overhead, ordering that is weaker than it looks, and the fact that remote actors turn this into a distributed-systems problem.

The question this answers

The question

What do you get by making a piece of state single-threaded by construction, and what does the mailbox cost you?

The work

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.

What is shared

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.

The invariant — what must stay true under every interleaving

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.

WorkCan it overlap?Can it parallelise?What is shared?What ordering?What synchronization?Where is contention?What can deadlock?What can race?What is gained?What complexity?

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).

Mailboxes are the only shared structures; state is unreachable from outside
Move(d4)Move(e5)dequeue oneread/write — exclusive by constructionBoardUpdaterestart on crashschedulesschedulesPlayer A socketPlayer B socketSupervisorMailbox (session-7743)Scheduler: N threads over 20 000 actorsMailbox (session-7742)Actor loopActor loop — one message at a timeboard, turn=41, players — private
ClientGateway / LBServiceWorkerDatabaseCacheQueue / LogObject storageCDNExternal system

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.

Per-pair FIFO holds throughout. The invariant still fails.ILLUSTRATIVE
Invariant · A move is only accepted from the player whose turn it is, and the client that sent a rejected move is told before it renders.
#Player A gatewayPlayer B gatewaySession actor (7742)Notification actorState
1send 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 workersent 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-42sent to clients=turn 42 after turn 43
✕ Clients render a board that moved backwards. No data race occurred; the ordering assumption was never guaranteed.
Sequential processing inside an actor removes concurrent mutation. It does not give you causal ordering across actors, and it evaporates entirely if a "logical actor" is actually a pool draining one mailbox. If order across actors matters, it must be carried explicitly — a sequence number the receiver checks, or a single actor that owns the ordering.

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.

ConcernMutex over shared structActorMessage passing with snapshots
Concurrent mutationPrevented by the lock, if every site takes itImpossible by constructionImpossible — one owner mutates
Cost per operationLock acquire/releaseEnqueue + schedule + dequeueEnqueue + snapshot cost
Scales to many independent unitsOne lock per unit; lock bookkeeping growsNatural — one actor per unitOne owner per unit
ReadsDirect, cheap, currentA request message and a reply — expensiveFree from a local snapshot, but stale
Ordering across unitsGlobal, if a single lock covers themNone — must be carried explicitlyNone
Failure isolationAn exception can leave the struct inconsistentSupervisor restarts the actor with fresh stateOwner crash loses the state
BackpressureBlocking on the lockOnly with a bounded mailboxOnly with a bounded queue
Becomes distributedNever — a local mutex means nothing remotelySilently, via location transparencyExplicitly, when you choose a broker
Actors against the alternatives, for one unit of contended state.

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.

How it works
  • 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.
Interleavings that matter
  • 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).
What it guarantees — and does not
  • 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.
Where contention appears
  • 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.
How it fails
  • 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 it helps
  • 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 it hurts
  • 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).
How you would know
  • 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.
Complexity it introduces
Simpler alternatives
  • 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

Claim

Actors eliminate race conditions.

Reality

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.

Claim

Actors eliminate deadlock.

Reality

They eliminate lock-ordering deadlock and introduce reply-cycle deadlock, which has no lock table to dump and usually presents as "everything is slow".

Claim

Location transparency means I can distribute later for free.

Reality

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.

Apply it