The Actor model
Each Actor has a private state and a message box. It talks to others only with messages and handles its messages one by one. So inside it there are never two jobs at the same time, and no lock is needed.
Author: bezzad
The problem: two buyers, one pair of headphones
Our online shop has a sale. Only one pair of headphones is left. Ali and Sara press the buy button at the same moment. The server runs each request on a separate thread.
Step by step:
- Ali’s request reads the stock. There is one item.
- Sara’s request reads it at the same moment. There is still one item, because Ali has not taken it away yet.
- Both accept the purchase. The stock becomes minus one.
We call this problem a Race Condition. The usual fix is a lock. But a lock has its own problems:
- It is easy to forget the lock. One new method without a lock is enough to bring the bug back.
- Locks create deadlocks. Two threads each hold one lock and wait for the other lock.
- A lock works only inside one server. If we have three servers, an in-memory lock does not help.
The main idea: do not share memory
The Actor model is an old idea (from the 1970s). The Erlang language is built on this idea. The idea is simple: instead of several threads touching one piece of data, give the data to one owner.
Each Actor has three things:
- A private state. For example, the number of headphones left. Nobody else reads or changes it.
- A message box (Mailbox). Anyone who needs something puts a message in this queue.
- A behavior. The code that runs for each message.
And one golden rule: messages are handled one by one. The next message does not start until the work of the current message is done.
When an Actor handles a message, it can do only these things:
- Change its own state.
- Send messages to other Actors. For example, the answer “purchase accepted”.
- Create a new Actor.
- Change its behavior for the next message. For example, after the stock runs out, reject all purchases.
Why is no lock needed?
- The state has only one owner. Only the Actor’s own code touches it.
- That code never runs twice at the same time. The message box gives the messages one after another.
- So no two jobs read and change the state at the same time. A Race Condition inside an Actor is not possible.
Live example
Try both cases and compare the results:
Five buyers press the buy button at the same moment. The stock is only three.
A simple Actor in C#
To understand the idea, we do not need any library. A Channel from the System.Threading.Channels namespace is the message box. A loop reads the messages one by one:
using System.Threading.Channels;
public sealed record Reserve(string OrderId, int Quantity, TaskCompletionSource<bool> Reply);
public sealed class StockActor
{
private readonly Channel<Reserve> _mailbox =
Channel.CreateUnbounded<Reserve>(new UnboundedChannelOptions { SingleReader = true });
private int _available; // private state: only RunAsync touches it
public StockActor(int available)
{
_available = available;
_ = Task.Run(RunAsync);
}
// Callers never touch the state. They only drop a message in the mailbox.
public Task<bool> ReserveAsync(string orderId, int quantity)
{
var reply = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
_mailbox.Writer.TryWrite(new Reserve(orderId, quantity, reply));
return reply.Task;
}
private async Task RunAsync()
{
// One message at a time. No lock needed.
await foreach (var message in _mailbox.Reader.ReadAllAsync())
{
var ok = message.Quantity <= _available;
if (ok) _available -= message.Quantity;
message.Reply.SetResult(ok);
}
}
}
Using it:
var headphones = new StockActor(available: 3);
var buyers = Enumerable.Range(1, 5)
.Select(i => headphones.ReserveAsync($"o-{i}", quantity: 1));
bool[] results = await Task.WhenAll(buyers);
Console.WriteLine(results.Count(ok => ok)); // always 3
Where does parallel work come from?
If each Actor does only one job at a moment, does the system not get slow? No, because there are many Actors.
- One Actor for each product. Buying headphones does not wait for buying a book.
- Actors are light. An Actor is only an object and a queue, not a thread. So you can have millions of Actors.
- Location does not matter. The sender has only the Actor’s address. The Actor can be on the same server or on another server. We call this feature Location Transparency.
Important rules
- Do not do blocking work inside an Actor. If one message waits ten seconds for an API, all the next messages wait ten seconds.
- Do not give the state out. If you send a changeable list in the answer, others can change it outside the Actor. Messages must be unchangeable, like a record.
- Watch out for request and reply cycles. If Actor A waits for an answer from B, and B waits for an answer from A, both get stuck forever. This is the same deadlock, only without locks.
- Do not build one global Actor. If all orders go through one Actor, that Actor becomes a bottleneck. Split the state into small parts: one Actor for each product or each shopping cart.
- A message may not arrive. Between two servers, the network can lose a message. For important work, ask for an answer or set a Timeout.
Common mistakes
| Mistake | Result | The right way |
|---|---|---|
| One Actor for all orders | Everything waits behind one queue. More servers do not help. | One Actor for each entity. |
| A long wait inside an Actor | The message box gets bigger and bigger. | Give the long job to another place and get the result with a message. |
| Sending a changeable object in a message | Two Actors touch the same data. The same Race Condition again. | An unchangeable message. |
| A asks B for an answer and B asks A | Deadlock and Timeout. | Remove the cycle in the design. |
| Thinking Actors solve all distributed problems | A lost or duplicate message surprises you. | Design retries, Timeouts and Idempotency yourself. |
When to use the Actor Model?
Good fit
- Many independent entities with their own state: shopping cart, game player, IoT device.
- Many requests at the same time on one entity.
- Hot state that you want to keep in memory, not read from the database each time.
Bad fit
- A simple CRUD app where the database handles concurrency well.
- Heavy calculation work with no state. Normal parallel code is simpler.
- Reports over all data. Actors are not built for queries over thousands of entities.
Summary in six lines
- Each Actor has a private state, a message box and a behavior.
- Nobody touches an Actor’s state. They only send a message.
- Messages are handled one by one. So no lock is needed inside an Actor.
- Parallel work comes from having many Actors, not from inside one Actor.
- Do not wait for a long time inside an Actor, and do not build request and reply cycles.
- In Production, use a library like Orleans or Akka.NET.