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.
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:
- Send a finite number of messages to addresses it knows: addresses it was created with, addresses it created, or addresses that arrived inside messages.
- Create a finite number of new actors, learning their addresses.
- 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.
| Property | Pure model | Erlang/OTP | Akka |
|---|---|---|---|
| Ordering | None | Messages from one process to another arrive in the order sent | Messages sent directly from one actor to another are not reordered (per sender-receiver pair) |
| Delivery | Eventual | Best effort; a dead or disconnected receiver silently drops | At-most-once; reliable delivery is an opt-in layer |
| Reply | Not built in | By convention: send self() and a reference | replyTo ActorRef in the message, or ask with a timeout |
| Failure | Unspecified | Links, monitors, exit signals, supervisors | Watch/Terminated, supervision strategies |
| Receive | Behaviour per message | Selective receive with pattern matching | Behaviour 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.
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:
- 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
-typespec, a sealed trait in Scala, a Java sealed interface). - 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.
- 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.
- 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.
- 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:
| Step | Message | Journal | What happens on crash here |
|---|---|---|---|
| 1 | withdraw 50, tx 17 to A | started | Restart re-sends withdraw; A sees tx 17 is new, applies it |
| 2 | ok from A | withdrawn | Restart sends deposit; never re-withdraws |
| 3 | deposit 50, tx 17 to B times out | withdrawn | Retry deposit; B deduplicates if the first one landed |
| 4 | B down after retries | compensated | refund 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.
| Hazard | How it arises | Mitigation |
|---|---|---|
| Deadlock | A 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 thing | Never make synchronous calls in a cycle; reply asynchronously and keep the request in state |
| Mailbox growth | Producer faster than consumer; unbounded mailboxes turn overload into memory exhaustion | Bounded mailboxes, work pulling, load shedding, monitoring queue length |
| Selective-receive stall | Unmatched messages accumulate and every receive rescans them | Catch-all clause; separate processes for separate protocols |
| Hot actor | One actor owns state everyone touches and becomes a serial bottleneck | Shard by key into many actors; keep handlers short |
| Blocking inside a handler | A slow I/O call stalls the actor and, on shared dispatchers, its neighbours | Offload 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
- Write out the message protocol for one component you own as a sealed type, including every error reply, before writing a handler.
- Audit every request-reply path for a timeout at the sender and a unique correlation reference.
- Find any place that assumes ordering across different senders and replace it with a version or sequence number in the message.
- Make every retried handler idempotent by recording applied request IDs, with a bounded retention window.
- Search for synchronous calls (
gen_server:call, Akka ask) that could form a cycle and convert one side to an asynchronous reply. - Add mailbox-length metrics and alerts for your busiest actors, and add a catch-all clause to every receive loop.
- Read the runtime internals in the actor runtime deep dive next, then compare with the channel model to know when not to use actors.