Actor Model

Give each piece of state one owner that only messages can reach and that handles them one at a time. What that removes, what an asynchronous send does and does not promise, how supervisors restart failed actors, and where actor systems still jam.

A call that does not wait

A network of electric-vehicle chargers keeps one object in memory per charging bay: whether the bay is free, which driver's session is running, how many kWh it has delivered. A driver taps Start in the app, and the server runs bay14.tell(new StartSession(5521)). The call returns in microseconds without waiting for bay 14: the request is most likely still sitting in a queue nobody has looked at yet, and the return tells the server nothing either way.

That is the actor model at work. Each bay is an actor: it owns its state, and no code outside it can read or write that state. The only way to affect an actor is to send it a message, which lands in the actor's mailbox, a queue in front of it. The actor takes messages out one at a time, and while handling one it can change its own state, send messages to actors whose address it has, and create new actors. Concurrency patterns weighs this against locks and an event loop; here we follow the one message, because what the model gives and what it costs both happen on its trip.

Three senders, one mailbox

tell is an asynchronous send, often called fire-and-forget: it puts the message in the mailbox and returns, and the sender learns nothing, not that the message was handled, and for a remote actor not even that it arrived. In the same second the charger reports a meter reading and an operator marks the bay out of service. Erlang and Akka promise that messages from one sender to one receiver reach its mailbox in the order they were sent, if they arrive at all. Between different senders nothing is promised, so the driver's Start and the operator's out-of-service can arrive either way round, and the bay needs a sensible answer for both orders.

Three senders, one mailbox, one handler at a time

Diagram components: Component, Component, Component, Queue, Actor Model.

Nobody calls bay 14 or touches its fields. Messages queue in its mailbox, and the actor takes the next one only after the current one is handled.

Bay 14 takes Start, checks that it is free, records the session and switches the charger on; the reading waits its turn. Since no other code touches the bay's fields and the bay runs one handler at a time, a second driver's Start cannot slip in between the check "free?" and the write "taken". Your code holds no lock: the only shared thing is the mailbox, which the runtime makes safe for concurrent senders, where shared memory would need the mutex of Mutex and semaphore.

Two conditions keep it true. A message must not carry anything the sender keeps changing, or it is shared memory again, without a lock; Erlang's data is immutable, while Akka leaves it to you to send immutable messages. And one at a time lasts only until the handler returns: a callback attached to a database call runs later, possibly on another thread while the actor handles its next message, so the result of slow work should come back as a message to the actor's own mailbox, which is what Akka's pipeTo does. A runtime that lets a handler await may or may not admit other messages during the wait, and where it does, a check made before the await can be stale after it, exactly as in Event Loop.

Waiting for an answer

To show "charging started", the app needs an answer. Request-reply between actors is two messages: the request carries the address to reply to, and bay 14 sends its answer there. Akka wraps this as ask, which returns a future that completes with the reply or fails after a timeout you must supply, because a reply can be lost or never sent. A timeout that fires says nothing about whether bay 14 started the session; what giving up does and does not undo is in Timeout.

Asking turns dangerous when the asker waits inside its handler. The site actor balances power across the bays of one site, and both it and bay 14 were written to block on the reply:

  1. The site actor takes a rebalance message, asks bay 14 for its current draw, and blocks until the reply comes.
  2. Bay 14 takes Start, asks the site actor for a power budget, and blocks until the reply comes.
  3. Each question waits in the other's mailbox, behind a handler that will not finish until it is answered.

Both stay stuck until a timeout fails them, or for good if there is none: a deadlock with no lock in sight. The fix is never to block in a handler. Send the question, record in the actor's state what it is waiting for, return, and finish when the reply arrives as a message of its own; or let requests flow one way only, parents asking children and never the reverse.

Blocking hurts even without a cycle. Akka does not give each actor a thread: a small pool of threads runs whichever actors have mail, which is how one process holds hundreds of thousands of bays. A synchronous two-second call to the charger vendor's API holds one of those threads, and a few dozen at once can stall every actor in the process. Such calls belong on a separate pool (Thread Pool), their results sent back as messages.

When bay 14 dies

A charger sends a malformed reading, and bay 14's parser throws mid-handler. Classic Akka arranges actors in a tree, where the actor that creates a child is its supervisor, and Erlang/OTP gives that job to dedicated supervisor processes; either way the supervisor is told when the child fails and decides what happens next. Akka Typed differs: the parent picks the strategy when it spawns the child, and a child given none is stopped. The site actor started bays 14 and 15 and supervises both. It can restart the failed child, stop it, or pass the failure up to its own supervisor, and Erlang/OTP supervisors carry a restart limit: more restarts than allowed within a time window, and the supervisor gives up and fails itself, handing the problem upward.

The site actor supervises its bays

Diagram components: Actor Model, Actor Model, Actor Model.

Bay 14 crashed on a bad reading. Its supervisor restarts bay 14 alone; bay 15 keeps charging, because nothing of bay 14's state was shared with it.

Erlang's convention is let it crash: instead of defending against every unexpected input, let the process die and have its supervisor start a fresh one from a known state. Private state keeps the crash contained, since nothing outside holds a half-written update. But what bay 14 already did outside stays done: the new bay 14 starts from its initial state and does not know the charger is on and a session is running unless it reloads that from storage or asks the charger. In Erlang and Akka the message that crashed it is not handed to the new instance, and depending on the runtime the rest of the mailbox can be lost too.

Death loses messages in a quieter way as well. A tell returned before delivery, so if the process or its machine dies with a message still queued or on the wire, the message is gone and the sender is never told; Akka calls its delivery at-most-once. When a lost Stop would bill a driver for an hour nobody charged, the application adds what the runtime lacks: an acknowledgement, a Retry when none comes, and an id on each message, the same on every attempt, that the receiver uses to drop the duplicates a retry creates, the technique of Idempotency Key.

The actor everyone writes to

One message at a time means an actor's own handlers never do more than one core's worth of work. The bays scale because there are thousands of them. Billing, written as one actor that receives every meter reading, does not: 5,000 bays reporting once a second send it 5,000 messages a second, and at 0.5 ms each it can handle 2,000. Its mailbox grows by 3,000 messages a second, and mailboxes in Erlang and in Akka's default configuration are unbounded, so it grows until memory runs out, every reading in it getting older.

Splitting the state along a key it already has, one billing actor per site or per account, spreads the load if the state really divides; a network-wide total then needs a second level that sums the partial ones. What a full mailbox should do, bounding it and slowing or refusing senders, is the question Back-Pressure answers.

Tip Bay 14's family is the drawing the Actor Model challenge grades: the site actor supervising two bay actors, a Queue node as each bay's mailbox, and a Component for the driver app, six of the eight nodes allowed. Label the edges tell, one at a time and supervises, and leave out the Mutex / Lock node in the palette: a drawing that needs it has two actors sharing state.

All Learn articles