Skip to main content
PRISM

The actor model

intermediate · commonly asked

The same counter, reached only through messages. Every interleaving is correct with no lock, no atomic and no ordering rule anywhere.

Enumerating the interleavings…

The problem it solves

Every other fix in this section controls when threads run. A lock removes orderings. An atomic makes an operation indivisible. A barrier forbids a crossing. All of them accept that two threads will reach the same memory and arrange for that to be safe.

The actor model does something different, and once you see it the appeal is obvious: it arranges for there to be no shared mutable state at all. An actor owns its data and nobody else can reach it. Requests arrive as messages and are processed one at a time by the owner. The interleaving space stops being relevant to correctness — not because it is small, but because nothing in it can produce a conflict. There is no operation any two threads can perform on the same variable, because only one thread can name it.

That is a much stronger position than “we locked everything correctly”. It is closer to “the bug is not expressible”.

The mechanism

An actor is state plus a mailbox plus a single-threaded loop that takes one message at a time and processes it to completion. Three rules follow:

  • Nobody touches another actor’s state. All communication is messages.
  • Messages are processed one at a time. Inside the handler, the actor is single-threaded and needs no locks.
  • Messages are values. Sending must not hand over a reference the sender keeps mutating, or the isolation is a fiction.

The property worth naming precisely: the mutual exclusion has not gone away — it has moved to one place you can point at. The mailbox is the serialisation point. That is a much better position than a lock, not because it is cheaper but because it is explicit and singular: every actor has exactly one, its location is obvious, and no code path can forget to use it.

That relocation is what makes actors scale organisationally. A lock discipline requires every contributor to know a convention. Actor isolation requires only that nobody can get a reference — which the runtime or the type system enforces.

What the enumeration shows

Three variants of the same task: increment a counter twice.

actors-shared is the baseline — two clients incrementing directly. 18 of 20 interleavings lose an update, the familiar lost update.

actor-model sends the same work as messages. All 24 interleavings are correct, with no lock, no atomic and no ordering rule anywhere in the program. Look at the source in the code panel: the clients build a message and send it, and neither of them touches count. There is nothing to protect because there is nothing shared.

The third thing to check is the claim that the actor is the serialisation point: across every schedule, the counter never exceeds its bound, because the actor applies messages one at a time. The mailbox is where the ordering happens, and it is visible in the timeline as the actor’s receive operations.

The numbers worth carrying

  • Shared counter: 18 of 20 interleavings wrong. Actor: 0 of 24. No synchronisation primitive in either program.
  • Message passing costs an allocation, a queue operation and a scheduling hop — roughly hundreds of nanoseconds, against tens for an uncontended lock. Actors are not a performance optimisation for a single hot counter.
  • What they buy instead is throughput under contention and, more importantly, a correctness property that does not depend on discipline. An actor with a deep mailbox absorbs bursts that would have queued on a lock.
  • Erlang processes are around 300 bytes each and millions per node is routine. JVM actor libraries are similar, because an actor is not a thread — it is a small object that a shared pool runs when it has mail.

Where it breaks down

Mailboxes are queues, and queues need bounds. An actor that receives faster than it processes grows its mailbox until memory runs out. This is exactly the bounded buffer problem, and unbounded mailboxes are the default in several libraries — which makes “the actor fell behind” an out-of-memory rather than a backpressure signal.

Request-response reintroduces waiting. ask — send a message and wait for a reply — turns message passing back into a blocking call, with all the deadlock potential that implies. Two actors that ask each other deadlock as surely as two threads taking locks in opposite orders, and it is the same cycle. Prefer tell and a continuation where you can.

Isolation depends on messages being values. Send a mutable object and keep a reference, and you have shared mutable state with extra steps. Erlang enforces this with immutable data; Akka relies on convention and it is the most common way real systems break the model.

Distributed transactions do not get easier. Work spanning several actors has no atomic commit. You need a saga, an idempotent protocol, or a design where the invariant lives inside a single actor. Choosing actor boundaries so that invariants do not cross them is the actual design work, and it is the same skill as choosing aggregate boundaries in domain modelling.

Ordering is per-sender-receiver pair at best. Two actors sending to a third have no defined relative order.

What people get wrong

“Actors avoid mutual exclusion.” They relocate it to the mailbox. Being able to say that is the difference between understanding the model and repeating its marketing.

“Actors are faster.” Slower per operation, better under contention, and dramatically better at not being wrong. Choose them for the third reason.

“An actor is a thread.” It is a small object scheduled onto a shared pool. Millions of actors on eight threads is the normal arrangement.

“Message passing means no deadlock.” ask cycles deadlock. Cooperating actors waiting on each other is the same graph with different nodes.

In production

Erlang and Elixir are the reference: processes, message passing, immutable data, supervision trees, and “let it crash” — which works precisely because actors are isolated, since a crashed actor cannot have left shared state corrupt. That connection between isolation and recoverability is the deepest idea in the model and the one most often missed when the pattern is ported.

Akka and Pekko bring it to the JVM; Orleans to .NET with virtual actors; Go’s goroutines-plus-channels is the same discipline expressed differently (“share memory by communicating”); Rust’s channels plus ownership give the isolation guarantee at compile time, which is the strongest version available.

The design advice that matters most: choose actor boundaries so that invariants live inside one actor. If a rule spans two actors, you have a distributed consistency problem — and you have chosen to have it. Getting the boundaries right is most of the work, and it is why actors reward domain modelling more than most concurrency tools do.

And bound your mailboxes, with a policy for what happens when they fill.

The follow-up questions

“How does the actor model avoid races?” — Nobody can reach anybody else’s state. Then say where the mutual exclusion went: the mailbox.

“What happens when an actor cannot keep up?” — The mailbox grows. Say what your bound and drop policy are; “unbounded” is an answer with consequences.

“Can actors deadlock?” — Yes, with ask cycles. Same circular wait, different resource.

“Where do you draw actor boundaries?” — Around invariants. Anything spanning two actors becomes an eventual-consistency problem, and that should be a decision rather than a discovery.

In an interview

The answer behind Erlang, Akka and Go channels, and a strong signal when someone can say where the serialisation actually went.

  • actors
  • message passing
  • ownership
  • mailbox

Run these next

The rest of patterns in practice