Levelwise
English
Actor Model

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.

Not reviewedWritten with AI helpReading time: 12 minExample of a sale in an online shopC# and .NET 10 code

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.

Headphones stock1Buyer AliBuyer Sara1. Reads: one is left1. Reads: one is left2. Purchase OK2. Purchase OKTwo sales, one itemBoth read the stock before the other one took it away
Both requests worked correctly, but together the result is wrong.

Step by step:

  1. Ali’s request reads the stock. There is one item.
  2. Sara’s request reads it at the same moment. There is still one item, because Ali has not taken it away yet.
  3. 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.

Ali buysSara buysReza buysHeadphones stock actorMessage boxMailboxReza buysSara buysAli buysone by onePrivate stateavailable = 3BehaviorRead message, change stateand send an answerNobody outside touches the state; they only send messages
Three messages arrived at the same time, but they lined up in the message box. The actor's behavior sees only one each time.

Each Actor has three things:

  1. A private state. For example, the number of headphones left. Nobody else reads or changes it.
  2. A message box (Mailbox). Anyone who needs something puts a message in this queue.
  3. 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?

  1. The state has only one owner. Only the Actor’s own code touches it.
  2. That code never runs twice at the same time. The message box gives the messages one after another.
  3. 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:

Live example: sale of the last three headphones

Five buyers press the buy button at the same moment. The stock is only three.

Message box
    Stock
    3

      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
      This code is only for understanding. In Production you need more things: what happens when an Actor fails, where the state comes from after a restart, and how Actors are spread over several servers. Libraries like Orleans and Akka.NET do these jobs.

      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.

      Headphones stockMessage 1Message 2one by onePhone stockMessage 1one by oneBook stockMessage 1Message 2one by oneAll of these work at the same time on different cores or servers
      Inside each actor we have order. Between actors we have parallel work.
      1. One Actor for each product. Buying headphones does not wait for buying a book.
      2. Actors are light. An Actor is only an object and a queue, not a thread. So you can have millions of Actors.
      3. 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

      1. Do not do blocking work inside an Actor. If one message waits ten seconds for an API, all the next messages wait ten seconds.
      2. 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.
      3. 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.
      4. 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.
      5. 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

      1. Each Actor has a private state, a message box and a behavior.
      2. Nobody touches an Actor’s state. They only send a message.
      3. Messages are handled one by one. So no lock is needed inside an Actor.
      4. Parallel work comes from having many Actors, not from inside one Actor.
      5. Do not wait for a long time inside an Actor, and do not build request and reply cycles.
      6. In Production, use a library like Orleans or Akka.NET.