The idea: share nothing, send messages

With threads, many pieces of code read and write the same memory, so they need locks to take turns. The actor model removes the shared memory instead. An actor is a small process that owns its state. Nobody else can read or write that state. The only way to ask an actor for anything is to send it a message. Erlang and Elixir processes, Akka actors (Scala, Java) and Microsoft Orleans grains all work this way.

Left: two threads go through one mutex to reach a balance in shared memory. Right: Client A and Client B put messages into the mailbox of the account actor, which holds a private balance that only it can touch
Threads share the balance and guard it with a lock; actors keep it private and line up messages in a mailbox.

The page has one actor, account, with balance = 100, and two clients that send it messages. A supervisor watches it.

Inside an actor: mailbox, behaviour, state

Every actor has three parts:

  • a mailbox: a queue of messages that arrived and wait, oldest first;
  • a behaviour: the code that handles one message (in Erlang a receive loop or a gen_server callback);
  • private state, here the balance.

The actor takes the oldest message, handles it to the end, and only then takes the next one. Inside one actor nothing runs in parallel, so the handler can read, check and change its state without a lock. Parallelism comes from having many actors: an Erlang node runs millions of them on all cores (Demo 3: a burst waits in the mailbox).

Asynchronous send and request–reply

Sending (Pid ! Msg in Erlang, tell in Akka) puts the message in the mailbox and returns at once. The sender does not wait and does not know when, or whether, the message is handled. That is a cast: {deposit,50} gets no answer.

A reply is just another message going the other way, so a request must say where to send it: {get,From} carries the sender's pid. gen_server:call and Akka's ask wrap this: they send the request, then block the caller (or give it a future) until the reply arrives or a timeout fires, 5000 ms by default in Erlang (Demo 1: deposit, then ask for the balance).

messagekindwhat account doesreply
{deposit,50}castbalance += 50none
{withdraw,70,From}callif balance ≥ 70: balance −= 70{ok,New} or {error,no_funds}
{get,From}callreads balance{balance,B}
{deposit,"ten"}cast100 + "ten" fails: badarithnone, the actor dies

No race without locks

Two clients withdraw 70 from 100 at the same moment (Demo 2: two withdrawals at once, no race). With a shared variable and no lock, both threads can read 100, both pass the check and both pay out: the classic check-then-act race. The actor cannot get this wrong. Both messages land in its mailbox in some order, and the second one is only looked at after the first one has finished.

Left: thread 1 and thread 2 both read 100, both see 100 is at least 70, both write 30, so 140 is paid out of 100. Right: the account actor takes withdraw 70 from A first, balance 100 to 30, replies ok 30; then withdraw 70 from B, 30 is less than 70, replies error no_funds
The mailbox turns two simultaneous withdrawals into a sequence, so only one of them can succeed.

The order between messages from different senders is whatever the mailbox received first. Erlang only promises that messages from one sender to one receiver arrive in the order they were sent. With threads you would fix the race with a mutex or a compare-and-swap loop.

Mailbox overload and dead letters

A sender can be faster than the actor. Then messages pile up in the mailbox, and the actor works through them at its own pace. Erlang and Akka mailboxes are unbounded by default: send never fails, but a mailbox that keeps growing eats memory until the node dies. Akka's bounded mailbox sets a capacity; a message that finds it full goes to dead letters, where it is only logged (Demo 5: bounded mailbox, overflow to dead letters, set under Options). Real systems add flow control on top, as on Backpressure. Compare the bounded buffer, where a full queue blocks the producer instead of dropping.

Let it crash: links and supervisors

Erlang's advice for unexpected errors is let it crash: don't write defensive code for every bad input; let the process die and start a clean one. A link between two processes turns one's death into an EXIT signal to the other. A supervisor traps those signals and restarts the child. With one_for_one it restarts only the child that died (Demo 4: let it crash, the supervisor restarts it).

A supervisor with three children: logger, account and audit. account crashes with badarith. After the restart the supervisor has restarts 1, and only account is new, with a new pid and balance 100; logger and audit kept running
A crash stays inside one actor: the supervisor replaces it and the others never notice.

The new process starts from init/1 with a new pid. Its old state is gone, and so is every message still in the old mailbox: a client blocked in a call gets no reply and times out. Clients reach the new process because it registers the same name, account. State that must survive a crash has to be saved somewhere else, in a database or with Akka Persistence. Expected errors, like too little money, are ordinary replies, not crashes. If a child crashes more than the supervisor's intensity allows (by default 1 restart in 5 seconds), the supervisor gives up and dies too, so its own supervisor can try a bigger restart. Compare the circuit breaker, which protects callers from a failing service.

Where you meet actors

systemactorsendsupervision
Erlang / Elixir (BEAM)process, about 2.7 KB at startPid ! Msg, gen_server:call/castOTP supervisors, links, monitors
Akka (JVM)actor, scheduled on a thread pooltell / !, ask / ?parent actors, restart / resume / stop
Microsoft Orleans (.NET)virtual actor ("grain"), activated on demandasync method callthe runtime reactivates a grain elsewhere

Go takes a nearby route: goroutines that talk over channels. The channel, not the goroutine, is the mailbox, and goroutines may still share memory. See The Go Scheduler.

What the page leaves out

Selective receive (taking a matching message out of order), monitors next to links, actors on several nodes and what network failures do to delivery (at-most-once by default), the scheduler that runs many actors on few cores with reductions, message copying between process heaps, timeouts and retries on the client side, and the other restart strategies (one_for_all, rest_for_one).

On the mind map

This page on the Concurrency mind map · 28 pages in 7 branches · open it to see the whole map and test yourself in recall mode.