The actor model is often taught as a library feature: a class with a mailbox, a thread pool behind it, and a supervisor somewhere. That framing hides the part that actually changes how you reason about concurrency. The model is a small set of rules about what a computation may do when a message arrives, and, just as important, a set of things it deliberately refuses to promise. If you know those rules you can predict which bugs an actor system removes, which ones it merely moves, and which new ones it introduces.

This article works from the rules outward. It starts with the three axioms Carl Hewitt, Peter Bishop and Richard Steiger introduced in 1973 and Gul Agha formalised in 1986, lists what the pure model leaves undefined, then shows how Erlang/OTP and Akka narrow those gaps into something you can build on. The code is Erlang because its primitives map one-to-one onto the model. The runtime machinery (mailbox implementations, dispatchers, restart intensity, virtual actors) is covered in the actor runtime deep dive and the Akka Typed idioms in the Akka interaction patterns article; here the focus is semantics and design.

Advertisement

Three axioms: send, create, become

An actor is an entity with an address and private state. Nothing outside it can read or write that state. The only thing anyone can do to an actor is send a message to its address. When the actor processes one message, it may do exactly three kinds of thing, in any combination and any number of times:

  1. Send a finite number of messages to addresses it knows: addresses it was created with, addresses it created, or addresses that arrived inside messages.
  2. Create a finite number of new actors, learning their addresses.
  3. Become: designate the behaviour it will use for the next message. This is how state changes; there is no assignment visible to anyone else.

Three consequences follow directly. First, an actor handles one message at a time, so code inside a handler never races with itself, and you need no lock to protect the actor's state. Second, the set of actors you can talk to is determined by the addresses you hold, so the communication topology is itself a capability system: you cannot message what you cannot name. Third, because the only interaction is an asynchronous send, the sender never waits for the receiver, and there is no shared memory through which two actors could interfere.

With a mutex, correctness depends on every caller following a locking discipline; here the discipline is structural. The cost is that every question you ask another actor becomes a protocol rather than a function call.

What the model does not promise

The pure model makes a short list of guarantees and a long list of non-guarantees. The guarantees: a message sent is eventually delivered (in Hewitt's formulation), and an actor processes messages one at a time. The non-guarantees are where most real bugs live.

  • No ordering. Two messages sent from A to B may arrive in either order. The model treats arrival order as nondeterministic.
  • No bound on delay. Delivery is eventual, with no time limit. William Clinger's 1981 thesis showed that this implies unbounded nondeterminism: an actor system can be guaranteed to halt yet have no bound on how long it takes, something a single sequential machine cannot express.
  • No reply. Request-reply is not a primitive. If you want an answer you must send your own address and hope a message comes back.
  • No failure semantics. The 1973 model does not say what happens when an actor crashes or a machine disappears. Real systems had to invent that.

Real runtimes narrow these gaps, and the narrowing differs by runtime. The table summarises what Erlang/OTP and classic or Typed Akka actually promise.

PropertyPure modelErlang/OTPAkka
OrderingNoneMessages from one process to another arrive in the order sentMessages sent directly from one actor to another are not reordered (per sender-receiver pair)
DeliveryEventualBest effort; a dead or disconnected receiver silently dropsAt-most-once; reliable delivery is an opt-in layer
ReplyNot built inBy convention: send self() and a referencereplyTo ActorRef in the message, or ask with a timeout
FailureUnspecifiedLinks, monitors, exit signals, supervisorsWatch/Terminated, supervision strategies
ReceiveBehaviour per messageSelective receive with pattern matchingBehaviour per message, stash for deferral

Per-pair ordering is weaker than it looks. It says nothing about messages from different senders. The diagram shows the classic triangle: A tells B something, then tells C, and C forwards to B. B can see C's message before A's original, even though A sent its message first. Any protocol that assumes a global order across senders is wrong under every mainstream runtime.

Actor Asends m1 then m2Actor BmailboxActor Cforwards m2 as m31: m1 (slow path)2: m23: m3 (fast)B may process m3 before m1: ordering holds per pair (A to B), never across sendersDesign rule: carry causality in the message (version, sequence number), not in arrival order
The ordering triangle. Erlang and Akka guarantee FIFO only between one sender and one receiver, so causally later messages that take another route can overtake earlier ones.
Advertisement

The axioms in Erlang

Erlang maps the axioms directly. spawn is create, ! is send, and become is a tail call with new state: the process loops by calling itself with the updated value. A bank account looks like this:

-module(account).
-export([start/1, loop/1]).

start(Balance) -> spawn(?MODULE, loop, [Balance]).

loop(Balance) ->
    receive
        {deposit, Amount, From, Ref} when Amount > 0 ->
            From ! {ok, Ref},
            loop(Balance + Amount);              % become: same code, new state
        {withdraw, Amount, From, Ref} when Amount =< Balance ->
            From ! {ok, Ref},
            loop(Balance - Amount);
        {withdraw, _Amount, From, Ref} ->
            From ! {error, Ref, insufficient_funds},
            loop(Balance);
        {balance, From, Ref} ->
            From ! {balance, Ref, Balance},
            loop(Balance)
    end.

Every request carries From (the reply address) and Ref (a unique reference from make_ref()). The reference is what makes request-reply safe: the caller waits for a message tagged with its own reference, so a late answer to an earlier, timed-out request cannot be mistaken for the current one.

Erlang's receive is selective: it scans the mailbox for the first message matching any clause and leaves the rest in place. That lets a process wait for one specific reply, but it is also a performance trap. If messages that match no clause accumulate, every receive rescans them, and the cost grows with the backlog. The compiler optimises the common pattern of creating a reference and immediately receiving on it, skipping messages that arrived before the reference existed, but a general clause such as matching any {reply, _} gets no such help. Two rules keep it safe: give every long-lived process a catch-all clause that logs and drops unknown messages, and watch erlang:process_info(Pid, message_queue_len) in production.

Giving failure a meaning: links and monitors

The model's silence on failure is where Erlang made its biggest contribution. Two primitives give failure a meaning. A link is bidirectional: if either linked process dies abnormally, an exit signal kills the other, unless the other has set trap_exit, in which case it receives an {'EXIT', Pid, Reason} message instead. A monitor is unidirectional and never kills: the watcher gets a {'DOWN', Ref, process, Pid, Reason} message.

%% A caller that must not die with the account, but must notice if it does.
call_balance(Account, Timeout) ->
    MRef = erlang:monitor(process, Account),
    Account ! {balance, self(), MRef},
    receive
        {balance, MRef, B} ->
            erlang:demonitor(MRef, [flush]),
            {ok, B};
        {'DOWN', MRef, process, _, Reason} ->
            {error, {account_down, Reason}}
    after Timeout ->
        erlang:demonitor(MRef, [flush]),
        {error, timeout}
    end.

Here the monitor reference doubles as the request reference, which is exactly what OTP's gen_server:call does internally. Use links to tie together processes that form one unit and should live or die together; that is the basis of supervision trees. Use monitors when one process depends on another but should outlive its failure. The philosophy usually summarised as "let it crash" follows: rather than defending against every error inside the handler, let the process die with a clear reason and let a supervisor restart it into a known-good state. It works because state is private; a crash cannot leave shared memory half-updated.

Designing an actor is designing a protocol

Since messages are the only interface, designing an actor means designing a protocol. A useful procedure:

  1. List the message types the actor accepts and, for each, the replies it can send, including errors. Write them down as a type (an Erlang -type spec, a sealed trait in Scala, a Java sealed interface).
  2. Draw the actor as a state machine. Each state is a behaviour; each transition is a become. Messages that are invalid in a state must have an explicit answer, usually an error reply, not silence.
  3. Decide delivery semantics per message. Because delivery is at most once, every request that matters needs a timeout at the sender and a retry policy. Retries mean duplicates, so make handlers idempotent: carry a request ID and remember recently applied IDs.
  4. Carry causality in data. If order across senders matters, include a version or sequence number and have the receiver reject or buffer out-of-order updates.
  5. Bound everything that can grow: mailboxes, per-actor caches, the set of remembered request IDs.

Worked example: a transfer without locks

Moving money between two accounts is the standard test, because with locks you would take both locks in a fixed order. Actors have no locks to take. The naive version sends withdraw to the source and, on success, deposit to the destination. If the coordinator crashes between the two, money vanishes. The fix is a dedicated transfer actor that runs a small saga and records its progress.

transfer(TxId, From, To, Amount, Journal) ->
    journal:record(Journal, TxId, started),
    case call(From, {withdraw, Amount, TxId}) of
        ok ->
            journal:record(Journal, TxId, withdrawn),
            case call(To, {deposit, Amount, TxId}) of
                ok    -> journal:record(Journal, TxId, done), ok;
                Error ->
                    %% compensate: put the money back, idempotently keyed by TxId
                    ok = call(From, {refund, Amount, TxId}),
                    journal:record(Journal, TxId, compensated),
                    Error
            end;
        Error ->
            journal:record(Journal, TxId, rejected),
            Error
    end.

The saga talks to an extended version of the earlier account protocol: requests are keyed by TxId, there is a refund clause, and each account keeps a bounded set of applied IDs, so a retry is a no-op, not a double charge. The journal is durable, so a restarted transfer actor can read the last recorded step and resume or compensate. And call uses a monitor and timeout as shown earlier, so a dead account produces an error rather than a hang. Trace one failure:

StepMessageJournalWhat happens on crash here
1withdraw 50, tx 17 to AstartedRestart re-sends withdraw; A sees tx 17 is new, applies it
2ok from AwithdrawnRestart sends deposit; never re-withdraws
3deposit 50, tx 17 to B times outwithdrawnRetry deposit; B deduplicates if the first one landed
4B down after retriescompensatedrefund tx 17 to A; balance restored

The model removed lock ordering and torn state inside an account. But atomicity across actors is gone: an observer can see A debited before B is credited. If that intermediate state is unacceptable, the two balances belong in one actor, or the operation belongs in a database transaction.

Hazards that survive the model

Actors remove data races, not concurrency bugs. The ones that remain look different.

HazardHow it arisesMitigation
DeadlockA makes a blocking call to B while B makes a blocking call to A; both wait. In OTP, gen_server:call defaults to a 5000 ms timeout, so this surfaces as a timeout crash, often blamed on the wrong thingNever make synchronous calls in a cycle; reply asynchronously and keep the request in state
Mailbox growthProducer faster than consumer; unbounded mailboxes turn overload into memory exhaustionBounded mailboxes, work pulling, load shedding, monitoring queue length
Selective-receive stallUnmatched messages accumulate and every receive rescans themCatch-all clause; separate processes for separate protocols
Hot actorOne actor owns state everyone touches and becomes a serial bottleneckShard by key into many actors; keep handlers short
Blocking inside a handlerA slow I/O call stalls the actor and, on shared dispatchers, its neighboursOffload to a dedicated pool and pipe the result back as a message

Backpressure deserves emphasis because the model gives you none: a send always succeeds locally. Flow control has to be built from messages, usually as demand signals where the consumer asks for N items, which is the design behind Reactive Streams backpressure.

Actors, channels or locks

Choosing between actors, channels and locks is a choice about what you name. Actors name the receiver: you send to an address, and the actor owns its state for its whole lifetime. Communicating Sequential Processes name the channel: anonymous processes rendezvous on shared channels, so the same process can serve several conversations and send can block until someone receives. That gives CSP built-in backpressure and makes deadlock detectable as a set of processes stuck on channel operations; actors trade that for location transparency and natural distribution. The mechanics of channel-based designs are in the channels article.

Use actors when you have many independent entities with their own lifecycle (sessions, devices, game objects, workflow instances), when failure isolation matters, or when the system must spread across machines. Use locks or atomics when the shared state is small, contention is low, and you need cross-field atomicity at nanosecond cost. Use channels or streams when the problem is a pipeline of transformations with flow control.

What to do next

  1. Write out the message protocol for one component you own as a sealed type, including every error reply, before writing a handler.
  2. Audit every request-reply path for a timeout at the sender and a unique correlation reference.
  3. Find any place that assumes ordering across different senders and replace it with a version or sequence number in the message.
  4. Make every retried handler idempotent by recording applied request IDs, with a bounded retention window.
  5. Search for synchronous calls (gen_server:call, Akka ask) that could form a cycle and convert one side to an asynchronous reply.
  6. Add mailbox-length metrics and alerts for your busiest actors, and add a catch-all clause to every receive loop.
  7. Read the runtime internals in the actor runtime deep dive next, then compare with the channel model to know when not to use actors.
Key takeaway: An actor can only send, create and become, and it processes one message at a time, which removes data races by construction. The pure model promises no ordering, no bounded delay, no replies and no failure semantics; Erlang and Akka add per-pair FIFO, monitors, links and supervision but stay at most once. Design actors as protocols: correlation references and timeouts on every request, idempotent handlers, causality carried in data, bounded mailboxes, and no synchronous call cycles.