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:
- spawn(state, handler) creates the mailbox and registers the actor with the scheduler; returns an address.
- send(addr, msg) enqueues and returns at once (or blocks / fails when a bounded mailbox is full). Delivery is best effort.
- turn: the scheduler takes a runnable actor, runs the handler on up to
budgetmessages, then yields. - 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.
- 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
receivescans 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
WouldBlockon 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
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
| name | lang | repo | install | verdict | licence | health | as_of | notes |
|---|---|---|---|---|---|---|---|---|
| Erlang/OTP | Erlang | erlang/otp | brew install erlang | — | — | — | — | the reference runtime: processes, links, monitors, distribution |
| Elixir | Elixir | elixir-lang/elixir | brew install elixir | — | — | — | — | runs on the BEAM; same processes and OTP supervisors |
| Apache Pekko | Scala/Java | apache/pekko | "org.apache.pekko" %% "pekko-actor-typed" % "1.7.1" (sbt) | — | — | — | — | typed actors, dispatchers, cluster; open fork of Akka |
| Akka | Scala/Java | akka/akka | see akka.io | — | — | — | — | original; docs cited here |
| Orleans | C# | dotnet/orleans | dotnet add package Microsoft.Orleans.Server | — | — | — | — | virtual actors (grains) |
| Proto.Actor | Go | asynkron/protoactor-go | go get github.com/asynkron/protoactor-go | — | — | — | — | also a .NET version, dotnet add package Proto.Actor |
| actix | Rust | actix/actix | cargo add actix | — | — | — | — | actor framework on tokio |
| ractor | Rust | slawlor/ractor | cargo add ractor | — | — | — | — | supervision and remote actors |
| kameo | Rust | tqwewe/kameo | cargo 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