Home › Concurrency › Task scheduling
algorithm medium

Actor model

Problem Servers with many independent stateful entities (rooms, devices, accounts, calls) get data races and lock contention when threads share their state, and they cannot spread the entities over machines without rewriting.

Isolated units of state that interact only by queued messages and handle one at a time: no locks on state, and the same code runs local or remote.

send O(1); receive O(1) FIFO, O(queue) for selective receive; memory O(actors + queued messages)
messagingmailboxisolationerlanglocation-transparency
Overview

Shared mutable state forces locks, and locks force deadlocks and contention. An actor owns its state privately and has a mailbox; the only way in is a message, and it handles one message at a time. So its state needs no lock, and a scheduler can run millions of actors on a few threads. Because actors only hold an address, the same send works for a local actor or one on another machine.

Key facts

Use when

  • Many independent stateful entities: one actor per game room, chat room or presence record, tracked vehicle, shopping cart or account.
  • Telecom: Erlang/OTP serves fault-tolerant switches such as the Ericsson AXD301 ATM switch.
  • Order-book or gateway code with one owner per instrument or session (the pattern, no vendor claim).
  • Orleans names gaming, banking, chat, GPS tracking, stock trading and shopping carts as uses.

Avoid when

  • One piece of state needs very high write rates from many threads: the actor is a serial bottleneck (use sharded counters or shard the actor).
  • A lock or a plain call is enough.

Deep dive

How it works

Parts:

  • actor — private state plus a behaviour (message in, state change and messages out)
  • mailbox — FIFO queue of incoming messages; bounded (sender blocks or is refused) or unbounded
  • address — handle used to send; local or remote, the sender cannot tell
  • scheduler — many actors on few threads, one message at a time per actor, a per-turn message budget for fairness
  • message types and ask — request carries a reply address and a timeout
  • lifecycle — spawn, stop, links and monitors (who is told when it dies)
  • supervision hook — a parent restarts or stops failed children, see Supervision trees

Operations:

  1. spawn(state, handler) creates the mailbox and registers the actor with the scheduler; returns an address.
  2. send(addr, msg) enqueues and returns at once (or blocks / fails when a bounded mailbox is full). Delivery is best effort.
  3. turn: the scheduler takes a runnable actor, runs the handler on up to budget messages, then yields.
  4. ask(addr, make_msg, timeout) creates a one-shot reply channel, sends the message with it, waits for the reply or the timeout. A timeout does not cancel the request.
  5. stop is itself a message (graceful) or a signal (kill). Links and monitors send a "down" message to observers.
 sender A ──msg──┐                         ┌──────── actor ────────┐
 sender B ──msg──┼──► [m1][m2][m3] mailbox ──► handler(&mut state, m)
 remote C ──msg──┘          ▲               │  state: private       │
                            │               │  may send / spawn     │
 scheduler: run actor ──────┘ takes ≤ budget └───────────────────────┘
 ask: msg carries reply Sender ◄──── reply ────────── handler

Guarantees. Erlang's only ordering guarantee: if A sends S1 and later S2 to B, S1 does not arrive after S2 (S1 may be lost); signals can be lost if the distribution channel goes down. Akka states two rules: at-most-once delivery and ordering per sender-receiver pair. Orleans grains finish one request before the next by default.

Code

Thread per actor with a bounded sync_channel as mailbox. The state moves into the thread, so no lock guards it. Each test below asserts one claim.

use std::sync::mpsc::{channel, sync_channel, Receiver, RecvTimeoutError, Sender, SyncSender, TrySendError};
use std::thread::{self, JoinHandle};
use std::time::Duration;

enum Env<M> { Msg(M), Stop }

pub struct Addr<M>(SyncSender<Env<M>>);
impl<M> Clone for Addr<M> {
    fn clone(&self) -> Self { Addr(self.0.clone()) }
}

impl<M: Send + 'static> Addr<M> {
    /// Blocks while the mailbox is full: backpressure. Err = actor is gone.
    pub fn send(&self, m: M) -> Result<(), M> {
        self.0.send(Env::Msg(m)).map_err(|e| match e.0 { Env::Msg(m) => m, Env::Stop => unreachable!() })
    }
    /// Never blocks: Full or Disconnected hands the message back.
    pub fn try_send(&self, m: M) -> Result<(), TrySendError<M>> {
        self.0.try_send(Env::Msg(m)).map_err(|e| match e {
            TrySendError::Full(Env::Msg(m)) => TrySendError::Full(m),
            TrySendError::Disconnected(Env::Msg(m)) => TrySendError::Disconnected(m),
            _ => unreachable!(),
        })
    }
    /// Request/reply: `make` embeds the reply Sender in the message.
    pub fn ask<R>(&self, make: impl FnOnce(Sender<R>) -> M, timeout: Duration) -> Result<R, RecvTimeoutError> {
        let (tx, rx) = channel();
        self.send(make(tx)).map_err(|_| RecvTimeoutError::Disconnected)?;
        rx.recv_timeout(timeout) // a timeout does not cancel the request
    }
    /// Queued behind earlier messages: they are all handled first (FIFO drain).
    pub fn stop(&self) { let _ = self.0.send(Env::Stop); }
}

/// One thread per actor; the state moves into it, so no lock. join() returns the
/// final state. Messages queued behind Stop die with the Receiver; later sends get Err.
pub fn spawn<S: Send + 'static, M: Send + 'static>(
    mut state: S, mailbox: usize, mut handler: impl FnMut(&mut S, M) + Send + 'static,
) -> (Addr<M>, JoinHandle<S>) {
    let (tx, rx): (SyncSender<Env<M>>, Receiver<Env<M>>) = sync_channel(mailbox);
    let h = thread::spawn(move || {
        while let Ok(Env::Msg(m)) = rx.recv() { handler(&mut state, m); }
        state
    });
    (Addr(tx), h)
}

enum Cmd { Add(u64), Get(Sender<u64>) }

fn main() {
    // N senders x M messages, exact total, state touched by one thread only.
    let (n, m) = (8u64, 250_000u64);
    let (acc, h) = spawn(0u64, 1024, |sum, c| match c {
        Cmd::Add(x) => *sum += x,
        Cmd::Get(r) => { let _ = r.send(*sum); }
    });
    let t0 = std::time::Instant::now();
    let senders: Vec<_> = (0..n).map(|_| { let a = acc.clone(); thread::spawn(move || for _ in 0..m { a.send(Cmd::Add(1)).ok().unwrap(); }) }).collect();
    for s in senders { s.join().unwrap(); }
    assert_eq!(acc.ask(Cmd::Get, Duration::from_secs(5)).unwrap(), n * m); // FIFO: Get sees all earlier Adds
    let dt = t0.elapsed();
    println!("{} msgs in {:?}: {:.2} M msg/s", n * m, dt, (n * m) as f64 / dt.as_secs_f64() / 1e6);

    let t1 = std::time::Instant::now(); // ask round trip
    for _ in 0..100_000 { acc.ask(Cmd::Get, Duration::from_secs(5)).unwrap(); }
    println!("ask round trip: {:?}", t1.elapsed() / 100_000);

    // Bounded mailbox: block the handler, fill the queue, try_send fails.
    let (gate_tx, gate_rx) = channel::<()>();
    let (slow, hs) = spawn(0u32, 2, move |c, _: ()| { let _ = gate_rx.recv(); *c += 1; });
    let mut accepted = 0; // the loop ends on the first Full
    while slow.try_send(()).is_ok() { accepted += 1; }
    assert!((2..=3).contains(&accepted)); // 2 queued, +1 if the handler already took one
    println!("accepted {accepted} before Full");

    // Stop drains what was queued before it, then later sends fail.
    for _ in 0..accepted { gate_tx.send(()).unwrap(); }
    slow.stop();
    assert_eq!(hs.join().unwrap() as usize, accepted);
    assert!(matches!(slow.try_send(()), Err(TrySendError::Disconnected(_))));
    acc.stop();
    assert_eq!(h.join().unwrap(), n * m);
    println!("ok");
}

Apple M4 Pro, release build, measured by me (6 runs): 8 senders x 250,000 Add messages into one actor: 10.7-12.3 M msg/s; ask round trip about 10-17 µs (busy machine). The thread-per-actor choice is the simplification: a real runtime multiplexes actors on a thread pool with a per-turn budget.

Complexity and constants

  • send: one queue push, O(1); handler dispatch O(1); memory O(actors + queued messages).
  • Erlang selective receive scans the mailbox until a match: O(queue length). Akka has no scan: the current behaviour must handle the next message.
  • Per actor: Erlang 327 words at spawn, of which 233 are heap (OTP 27 guide). Thread-per-actor here: spawning 2,000 took about 17 ms (order of 8 µs each), but 10,000 thread-actors failed with WouldBlock on this Mac (measured; the OS limits threads).
  • Hot actor (measured, 3 runs): 1 sender 73-87 M msg/s, 2 senders 37-53, 8 senders 9-14: queue contention, not the handler, is the limit.

Efficiency

  • Cost: per message on an M4 Pro (measured, thread per actor, 1,024-slot mailbox): about 12 ns with one sender, 70-110 ns with 8 senders on one actor, 10-17 µs for a full ask (two cross-core wakeups). Per actor: 327 words in Erlang (guide); about 8 µs to spawn plus an OS thread stack here. Bound: queue contention and wakeups.
  • Levers:
    • batch N items per message — queue cost per item falls toward 1/N (arithmetic) — bursty producers
    • shard a hot actor by key (hash mod N or consistent hashing) — attacks the contention measured above — one actor saturates
    • per-turn budget (Akka throughput: max messages per actor before the thread moves on; 1 is fairest, Akka docs) — fairness against switches — shared pools
    • bounded mailbox, try_send — memory stays bounded; Akka docs: unbounded can run out of memory — fast producers
    • reply tagged with a fresh reference — Erlang skips the mailbox scan (efficiency guide) — ask on long queues
  • Naive → standard → state of the art:
    • shared state behind a mutex, threads fighting · contention and blocking grow with threads
    • actor per thread plus bounded channel (this page) · about 10-17 µs ask, tens of thousands of actors at most
    • M:N scheduler with light processes, per-turn budget · Erlang's default 233-word heap is sized for hundreds of thousands to millions of processes (guide)

Implementation notes

  • Messages are moved or copied: Rust moves ownership through the channel; Erlang copies terms between heaps; Akka local sends pass a reference, remote sends serialise.
  • Never block in a handler on a shared scheduler: it removes a thread from every actor on it (Akka docs advise a dedicated dispatcher for blocking calls).
  • Reply-to inside the message is the standard request-reply; a timeout is the only failure signal, so make requests idempotent.
  • Size a bounded mailbox as tolerated latency x service rate, and reject at the edge (backpressure).

Pitfalls

  • Deadlock by mutual ask: A asks B while B asks A; both wait for replies neither can handle. Use timeouts, or reply by message.
  • Mailbox overflow: an unbounded queue turns overload into memory growth and late answers to everything.
  • Selective receive on a long queue (Erlang) is O(queue); unmatched messages also pile up.
  • Hot actor: one actor for a global key serialises all work; shard it.
  • Assuming exactly-once: retries after timeouts duplicate work; use ids.
  • Cross-sender order: only per-pair order is promised.
  • Stop semantics: messages queued behind a stop are dropped unless you define a drain (as above).

Variants and alternatives

Virtual actors (Orleans grains): activated on demand, deactivated when idle, addressed by identity; no explicit create or destroy.
Typed actors (Akka Typed): the message type is part of the address type.
CSP channels (Go): the queue belongs to the channel, not the process.
Event sourcing: persist an actor's messages as its state; reload by replay.

In production

Erlang/OTP and Elixir (telecom switches), Akka and Apache Pekko (JVM services, clusters), Microsoft Orleans (cloud services; Microsoft names Xbox, Halo, Skype and PlayFab), Proto.Actor (Go and .NET), Rust: actix, ractor, kameo.

Implementations

namelangrepoinstallverdictlicencehealthas_ofnotes
Erlang/OTPErlangerlang/otpbrew install erlang————the reference runtime: processes, links, monitors, distribution
ElixirElixirelixir-lang/elixirbrew install elixir————runs on the BEAM; same processes and OTP supervisors
Apache PekkoScala/Javaapache/pekko"org.apache.pekko" %% "pekko-actor-typed" % "1.7.1" (sbt)————typed actors, dispatchers, cluster; open fork of Akka
AkkaScala/Javaakka/akkasee akka.io————original; docs cited here
OrleansC#dotnet/orleansdotnet add package Microsoft.Orleans.Server————virtual actors (grains)
Proto.ActorGoasynkron/protoactor-gogo get github.com/asynkron/protoactor-go————also a .NET version, dotnet add package Proto.Actor
actixRustactix/actixcargo add actix————actor framework on tokio
ractorRustslawlor/ractorcargo add ractor————supervision and remote actors
kameoRusttqwewe/kameocargo add kameo————fault-tolerant async actors on tokio

Sources

  • Carl Hewitt, Peter Bishop, Richard Steiger, A Universal Modular ACTOR Formalism for Artificial Intelligence, IJCAI 1973. paper
  • Gul Agha, Actors: A Model of Concurrent Computation in Distributed Systems, MIT Press 1986. book
  • Erlang/OTP reference manual, Processes (signals, ordering guarantee). docs
  • Erlang/OTP efficiency guide, Processes (process size, selective receive). docs
  • Akka documentation, Message Delivery Reliability. docs; Mailboxes. docs; Dispatchers. docs
  • Microsoft Learn, Orleans overview and Request scheduling. docs
  • Philip A. Bernstein, Sergey Bykov, Alan Geller, Gabriel Kliot, Jorgen Thelin, Orleans: Distributed Virtual Actors for Programmability and Scalability, MSR-TR-2014-41, 2014. report

Builds on

Used by

Systems (11):

Alternatives and related