Actors vs CSP
Two ways to share by communicating: actors with private state and a mailbox (Erlang, Akka, Web Workers) vs anonymous workers joined by channels (Go). How each works, how to build an actor in Go and JavaScript, supervision, mailbox overflow, and when to pick which.
The 30-second version
When several tasks run at the same time, the dangerous thing is a variable that more than one of them can change. Two tasks update it at once, and one update silently disappears (§02 shows exactly how). Two well-known approaches — actors and CSP — both start from one idea: don't share the variable at all; send messages instead.
Actors
CSP
Same goal
The map of this page
- §02–§04 (level 1): the problem, the actor model, and how it differs from channels — with three things to click.
- §05–§07 (level 2): build an actor in Go and in JavaScript, then see what Erlang and Akka add: supervisors that restart crashed actors.
- §08–§10: what goes wrong in production (overflowing mailboxes, message order, deadlocks), and how to choose.
- §11–§12: quiz and cheat sheet.
The problem: two writers, one variable
Take a bank account with 100 in it. Two goroutines each deposit 50 at the same moment. You expect 200. The trouble is that balance += 50 isn't one step for the computer — it's three: read the balance, add 50, write the result back. If both goroutines read before either one writes, both write 150, and one deposit vanishes.
Step through it, then flip the toggle to see the same job done by a single owner:
1balance := 10023deposit := func() { // "balance += 50", spelled out4 tmp := balance // 1. read5 tmp = tmp + 50 // 2. add6 balance = tmp // 3. write back7}8go deposit() // goroutine A9go deposit() // goroutine B
balance += 50 is really three steps — read, add, write — and goroutines can run at the very same moment on different CPU cores. So the steps of A and B can interleave.When two goroutines touch the same variable at the same time, at least one writes, and nothing coordinates them, it's called a Data race. Go even ships a tool to catch it: run your tests with go test -race.
Three ways out
| Approach | The idea | Examples |
|---|---|---|
| Lock | Everyone may touch the data, but only one at a time. Others wait their turn. | sync.Mutex — see Channels §10 |
| Actor | One worker owns the data. Others send it messages; it handles them one at a time. | Erlang, Akka, the toggle above; §03 explains it |
| CSP | The data moves between workers through channels. Whoever holds it right now is the only one using it. | Go pipelines — see Channels §06 |
A Lock keeps the data where it is and makes tasks queue up for it. The other two are both message passing: instead of reaching into shared memory, tasks send each other values. This page is about those two, and how they differ.
The actor model: people with inboxes
The idea comes from Carl Hewitt in 1973. An Actor has three parts:
Private state
A mailbox
Behavior
deposit, add to balance". It runs for one message at a time, start to finish.What an actor may do with a message
While handling one message, an actor is allowed to do exactly three kinds of things:
- Change its own state — "balance is now 150".
- Send messages to actors it knows the address of — including a reply to whoever asked.
- Create new actors — e.g. a "bank" actor that opens a new account actor for each customer.
That's it. It cannot read another actor's state or call its methods. The only way to affect another actor is to send it a message, which that actor will handle in its own time.
Try it
Each card is an actor: its locked private state (🔒), the message it's handling right now, and its mailbox. Every tick (about one second), each actor handles one message. They all work at the same time — but each one handles its own messages strictly one after another.
Things to notice:
- Burst five deposits while paused. They don't collide — they wait in line, and the balance goes up by 10 five times.
- A transfer is one message to alice. While handling it, alice changes her balance and sends a
depositto bob. Bob handles it later, on his own schedule. For a moment the money is "in the mail" — in neither balance. That's normal for actors: each actor's own state is always consistent, but the system as a whole catches up message by message. - Withdraw more than the balance: pick @bob (50) and press withdraw 30 twice. The first succeeds; the second is refused. The check ("is there enough?") and the change happen inside one message, so nothing can sneak in between them. With a shared variable, two withdrawals could both pass the check and overdraw the account — a cousin of the race in §02. Here it can't happen.
- @bank: open account makes the bank actor create a brand-new actor.
- Every change also sends a record to @audit. Nobody waits for the audit log; it catches up on its own.
CSP in one paragraph — and the real difference
CSP in one paragraph. CSP (Tony Hoare, 1978) builds programs from independent workers that never share variables; they talk only through channels. In Go that means goroutines plus ch <- v and v := <-ch. An unbuffered channel stores nothing: the sender waits until a receiver takes the value, so the two meet — a handshake (called a rendezvous). A Buffered channel adds N slots so the sender can run ahead. Either way, the value you send is handed over: once sent, it's the receiver's to use — that's what "the data moves" meant in §02. The Channels topic teaches all of this in depth — this page only needs the basics.
The two differences that matter
1. What has the name. With actors, the worker has an address and you send to it: "Alice, do this". With CSP, the channel has the name and the workers are anonymous: you put a job on jobs, and any goroutine reading jobs might take it.
2. Whether the sender waits. An actor's mailbox is a queue, so sending returns immediately — the message waits, not you. (In Erlang and Akka the mailbox has no size limit by default; §08 shows why that's a mixed blessing.) An unbuffered channel makes the sender wait for a receiver. (A buffered channel is in between: it's a small mailbox with a size limit.)
Watch both with the same job — a fast producer, a slow receiver:
Try 2 receivers: on the channel side, either receiver may take each value — the producer never named one. On the actor side nothing changes, because you addressed @printer. Then try buffer 3 and notice the channel starts to behave like a mailbox.
Side by side
| Actors | CSP (Go channels) | |
|---|---|---|
| You send to… | an actor (its address) | a channel; any receiver may take it |
| Does the sender wait? | No — the message waits in the Mailbox (unless the mailbox has a size limit and is full, §08) | Unbuffered: yes, until a receiver takes it. Buffered: only when full |
| Inboxes per worker | Exactly one mailbox | As many channels as you like, read with select (see the Go primer) |
| What it's best at | Things with identity and state: an account, a chat room | Flows of data: pipelines, worker pools |
| Across machines | Built in for Erlang and Akka (§07) | Within one program only |
| When a worker crashes | Supervisors restart it (§07) | Up to you |
Building an actor in Go
Go has no actor keyword, but it has everything needed: a Goroutine to run the actor, a Buffered channel as its Mailbox, and a for loop that takes one message at a time. Callers make two kinds of requests: a tell ("deposit this", send and move on) and an ask ("what's the balance?", wait for a reply). Here's the account from §03 as a complete program:
package main
import (
"context"
"fmt"
"sync"
)
// The messages this actor understands.
type deposit struct{ amount int }
type getBalance struct{ reply chan int } // carries its own "return address"
type Account struct {
inbox chan any // the mailbox; its channel is the actor's address
}
func NewAccount(ctx context.Context) *Account {
a := &Account{inbox: make(chan any, 64)} // up to 64 messages can wait
go a.loop(ctx) // start the actor's goroutine
return a
}
// loop IS the actor. Only this goroutine ever touches bal.
func (a *Account) loop(ctx context.Context) {
bal := 0 // private state
for {
select {
case msg := <-a.inbox: // take ONE message…
switch m := msg.(type) { // …see what kind it is…
case deposit:
bal += m.amount // …and handle it completely
case getBalance:
m.reply <- bal
}
case <-ctx.Done(): // someone asked the actor to stop
return
}
}
}
// Friendly methods, so callers never build messages by hand.
func (a *Account) Deposit(n int) { a.inbox <- deposit{n} } // "tell": don't wait for a result
func (a *Account) Balance() int { // "ask": wait for the reply
reply := make(chan int, 1)
a.inbox <- getBalance{reply}
return <-reply
}
func main() {
ctx, cancel := context.WithCancel(context.Background())
defer cancel() // stops the actor when main returns
acct := NewAccount(ctx)
var wg sync.WaitGroup
for i := 0; i < 100; i++ {
wg.Add(1)
go func() { defer wg.Done(); acct.Deposit(1) }() // 100 goroutines, no lock
}
wg.Wait() // all 100 deposits are now in the mailbox…
fmt.Println(acct.Balance()) // …and getBalance lines up behind them: 100
}A few details carry the whole design:
- The inbox channel is the actor's address. Whoever holds
acct(and soacct.inbox) can send it messages. Nobody can reachbal. - The buffer is the mailbox size. If 64 messages are already waiting,
Depositblocks until the actor catches up. That's Backpressure — a feature Erlang and Akka mailboxes don't give you by default (§08). ctx.Done()stops the actor. Without it, the goroutine would wait on the inbox forever after everyone stops using it — a goroutine leak (see Channels §08). Note the flip side: once the actor has stopped, any messages still queued are never handled, and a laterDepositblocks forever as soon as the buffer is full. §10 (pitfall #7) covers how to guard against that.
Asking a question: the reply channel
Deposit is a tell: drop the message and move on. Balance is an ask: the caller needs an answer, but it can't read bal itself. So it makes a fresh channel, puts it inside the request, and waits on it. Step through it:
1func (a *Account) Balance() int {2 reply := make(chan int, 1) // my private "return address"3 a.inbox <- getBalance{reply} // the request carries it4 return <-reply // wait for the answer5}67// inside the account's loop:8case getBalance:9 m.reply <- bal // answer on the caller's channel
deposit{50}; it's waiting in the mailbox. Now our caller wants to know the balance. It can't read bal — that variable belongs to the account goroutine — so it has to ask.Two choices in that code matter:
make(chan int, 1)— one slot. The actor'sm.reply <- balcan always complete, even if the caller already gave up and left. With an unbuffered reply channel, a caller that stopped waiting (you'll see such a caller in the timeout version below) would leave the actor stuck on that send — and a stuck actor stops reading its whole mailbox.- The answer reflects the mailbox order.
getBalancewas handled after the deposit that arrived before it, so the caller sees 150. Each answer is consistent with every message ahead of it in the queue.
The production version: never wait forever
The simple Balance() waits forever if the actor has stopped or is overloaded. Real code passes a context.Context and gives up when it's cancelled or times out — the same thing Akka calls an ask timeout:
func (a *Account) Balance(ctx context.Context) (int, error) {
reply := make(chan int, 1)
select {
case a.inbox <- getBalance{reply}: // the request got into the mailbox
case <-ctx.Done():
return 0, ctx.Err() // mailbox full for too long, or caller cancelled
}
select {
case v := <-reply:
return v, nil
case <-ctx.Done():
return 0, ctx.Err() // gave up waiting; the 1-slot reply channel keeps the actor safe
}
}
// Caller: give it at most 200 ms.
// ctx, cancel := context.WithTimeout(ctx, 200*time.Millisecond)
// defer cancel()
// bal, err := acct.Balance(ctx)Actors in JavaScript: a mailbox class and Web Workers
postMessage as its address.JavaScript runs your code on one Thread, so you might think it doesn't need actors. Without any await, that's true: two pieces of JS never run at the same instant. But an async handler pauses at every await — and while it's paused, another call can start and read the same state. That's the race in Async / Await §07. A mailbox fixes it by refusing to start the next message until the current one is completely done:
await auditLog.write(); amber = waiting in the mailbox. Without a mailbox, deposit b starts while a is paused, both read balance = 0, and the final balance is 50 instead of 100 (the race from the Async/Await topic). With a mailbox, b waits in the queue until a is completely done, so b reads 50 and writes 100.The mailbox class
class Actor {
#mailbox = []; // waiting messages, oldest first
#running = false; // is the loop below already working?
constructor(handler) { this.handler = handler; } // handler(msg) may be async
send(msg) { // "tell": queue it and return at once
this.#mailbox.push(msg);
if (!this.#running) { // no loop yet? schedule one
this.#running = true;
queueMicrotask(() => this.#drain()); // runs right after the sender's current code
}
}
ask(msg) { // "ask": the promise is the reply channel
return new Promise((resolve, reject) => this.send({ ...msg, resolve, reject }));
}
async #drain() {
while (this.#mailbox.length) {
const msg = this.#mailbox.shift(); // take the oldest message
try {
await this.handler(msg); // ← finish it completely before the next
} catch (err) {
if (msg.reject) msg.reject(err); // an ask gets the error back
else console.error("actor failed on", msg, err);
}
}
this.#running = false;
}
}Here is the loop at work, with a handler that pauses at an await:
1send(msg) {2 this.#mailbox.push(msg);3 if (!this.#running) {4 this.#running = true;5 queueMicrotask(() => this.#drain());6 }7}8async #drain() {9 while (this.#mailbox.length) {10 const msg = this.#mailbox.shift();11 await this.handler(msg);12 }13 this.#running = false;14}1516// handler: log "start", await sleep(10), log "end"17actor.send("a");18actor.send("b");
send("a") pushes the message into the mailbox. Nothing else has happened yet.Why is #running safe without a lock? Because the only gaps where other code can run are the awaits. The final check while (this.#mailbox.length) and this.#running = false run together with no await between them, so a send can never slip in and find the loop "half-stopped" — it either sees #running === true and its message gets picked up by the loop, or sees false and schedules a new one.
Here is the same account as §05 in both languages. Switch tabs to compare:
function makeAccount() {
let balance = 0; // private: only the handler can see it
return new Actor(async (msg) => {
switch (msg.type) {
case "deposit":
await auditLog.write(msg); // some async API: a pause — but no other
// message can start until this one finishes
balance += msg.amount;
break;
case "balance":
msg.resolve(balance); // answer the ask
break;
}
});
}
const acct = makeAccount();
acct.send({ type: "deposit", amount: 50 }); // tell
console.log(await acct.ask({ type: "balance" })); // ask → 50
// (await at the top level like this works in ES modules: .mjs files or <script type="module">)The balance query returns 50, not 0, for the same reason as in §05: it sits behind the deposit in the mailbox, and the deposit's await finishes before the next message starts.
Web Workers: actors built into the platform
A Web Worker (or a Worker thread in Node) runs JavaScript on another thread with its own memory. The page can't touch the worker's variables, and the worker can't touch the page's. They communicate only with postMessage, and each side handles incoming messages one at a time through its own Event loop. Private state, a mailbox, one message at a time — that's an actor.
const counter = new Worker(new URL("./counter.js", import.meta.url), { type: "module" });
counter.onmessage = (e) => console.log("total is", e.data); // replies arrive here
counter.postMessage({ type: "add", n: 5 }); // tell: returns at once
counter.postMessage({ type: "add", n: 2 });
counter.postMessage({ type: "get" }); // → "total is 7"postMessage copies the data with the Structured clone algorithm, so the worker gets its own copy — exactly like Erlang, where messages are copied between processes. (The exceptions are transferable objects such as an ArrayBuffer that you list in the transfer list — postMessage(buf, [buf]) — which are moved instead of copied, and SharedArrayBuffer, which really is shared — and brings back every problem from §02.)
Erlang, Elixir and Akka: supervisors and “let it crash”
Erlang was built at Ericsson in the 1980s for telephone switches that must never go down. Elixir (2012) is a friendlier language on the same virtual machine, the BEAM. Both call their actors processes — not operating-system processes, but tiny ones managed by the BEAM, starting at a few kilobytes each. A single machine can run millions. Akka brings the same ideas to the JVM (Scala/Java); since its 2022 license change, Apache Pekko is its open-source fork.
An actor in Elixir
Erlang-family languages have actors built in: spawn creates one, send drops a message in its mailbox, receive takes the next message that matches a pattern.
defmodule Counter do
def loop(count) do # the actor's state is the argument
receive do # take the next matching message
{:add, n} ->
loop(count + n) # "change state" = loop again with a new value
{:get, from} ->
send(from, {:count, count}) # reply to whoever asked
loop(count)
end
end
end
pid = spawn(fn -> Counter.loop(0) end) # create an actor; pid is its address
send(pid, {:add, 5}) # tell: returns immediately
send(pid, {:get, self()}) # ask: "reply to me" (self() = my own pid)
receive do
{:count, c} -> IO.puts(c) # 5
endIn real Elixir code you rarely write receive by hand. GenServer wraps this loop for you: GenServer.cast is a tell, and GenServer.call is an ask that waits for the reply — with a 5-second timeout by default, so a stuck actor can't hang the caller forever.
Supervisors and "let it crash"
Here's the twist that makes Erlang famous. A Supervisor is an actor whose only job is to start some child actors, watch them, and restart any that crash. Supervisors can supervise other supervisors, forming a supervision tree. The whole app is one tree.
That makes a different error-handling style possible: Let it crash. Don't wrap every line in defensive checks for situations you didn't expect. If a chat-room process gets into a weird state, let it crash; its supervisor starts a fresh copy in a known-good state a millisecond later. Crashing is safe because the room's state was private — nothing else was half-updated when it died.
What the demo shows:
- The restart strategies.
one_for_one: restart just the crashed child — the usual choice.one_for_all: restart all children — for children that can't work without each other.rest_for_one: restart the crashed child and all children started after it — for when later children depend on earlier ones. - Restart limits. Crash rooms quickly: after too many restarts in a short window (Elixir's default is 3 in 5 seconds; the demo uses 10 s), the supervisor decides restarting isn't helping, stops its children and crashes itself. Its own supervisor then restarts that whole branch. Failures climb the tree until a level that can fix them — and the logger, in another branch, never notices.
- Restarting loses in-memory state. The room comes back empty. "Let it crash" only works if anything important was saved somewhere durable (a database, an event log) before the crash.
Across machines: location transparency
A message to an Erlang process looks the same whether that process is in your program or on another machine: send(pid, msg). The BEAM connects nodes over the network and routes the message. This is called Location transparency. Akka Cluster and Microsoft Orleans do the same on their platforms — Orleans even creates an actor automatically the first time someone sends it a message ("virtual actors").
It works because actors were never sharing memory in the first place: a message was already a copy sent to an address. Go channels don't stretch this way — a channel is a value in one program's memory, and an unbuffered send's handshake can't cheaply span a network.
Mailboxes in production: overflow, backpressure, ordering
Overflow: a worked example
An actor handles 5 messages per second. Messages arrive at 8 per second. Every second, 3 more join the queue than leave it:
- after 1 minute: ~180 messages waiting; a new message waits ~36 s before it's handled;
- after 1 hour: ~10,800 waiting; a new message waits ~36 minutes.
Nothing errors. The wait for each message (its Latency) just climbs until callers time out, and memory climbs until the process is OOM-killed. Erlang and Akka mailboxes are unbounded by default, so this is the classic actor outage. Try the three policies:
The three options, and when each fits:
| Policy | What happens when full | Good for |
|---|---|---|
| Unbounded | Nothing — the queue grows | Only when you know the arrival rate is lower than the handling rate |
| Bounded, sender waits | The sender blocks until there's room: Backpressure | Work that must not be lost; a Go actor with make(chan any, 64) behaves like this |
| Bounded, drop / reject | The new message is thrown away or the send fails | Metrics, telemetry, "latest position" updates; or rejecting requests quickly (Load shedding) |
Ordering: what you can and can't rely on
Messages from one sender to one receiver arrive in the order they were sent — Erlang and Akka both promise that, and a Go channel is first-in-first-out too. But there is no global order. When messages travel along different paths, a later message can overtake an earlier one:
The practical rules:
- Two messages from you to one actor: safe to rely on their order. (Quiz question 1 depends on this.)
- Messages from several senders: they interleave in whatever order they arrive. If order matters, have one actor sequence them, or put a sequence number in each message.
- Messages that travel different paths (the right side of the diagram): A sent x to C before it sent y to B, and z only exists because B got y. It feels like x "must" reach C before z — but nothing promises it. Don't build logic on that kind of cause-and-effect order across actors unless your protocol enforces it (for example, A waits until C confirms x before sending y).
Actors or channels? How to choose
Shapes that want actors
Something that has an identity, lives for a long time, keeps state, and receives requests from many places:
- A chat room. One actor per room holds the member list and recent messages. Joins, leaves and posts arrive from many users; the room handles them one at a time, so "post" can never see a half-updated member list.
- A game entity or a game match. One actor per match owns positions and scores and processes moves in order.
- A bank account, a shopping cart, a user session, a device connection. One actor per id. Millions of mostly idle actors are cheap on the BEAM — and the per-id split is how you scale: different accounts are handled in parallel, while each single account stays strictly serial.
Shapes that want channels
Data that flows through steps, where no step has to remember much between items:
- Pipelines: read → parse → transform → write (Channels §06).
- Worker pools and fan-out: "resize these 10,000 images with 8 workers" (Channels §07). The workers are interchangeable, so naming them would only get in the way.
- Coordination signals: "stop now" (
ctx.Done()), "first result wins" (select), timeouts.
The decision table
| Question | → Actor | → Channels | → Lock |
|---|---|---|---|
| Is there a named, long-lived thing with state? | ✅ | ||
| Does data flow through stages? | ✅ | ||
| Are workers interchangeable? | ✅ | ||
| Is it a tiny shared value (a counter, a cache map)? | ✅ (Channels §10) | ||
| Must it survive crashes / run on many machines? | ✅ with supervisors, cluster (§07) | ||
| Should the sender be slowed when the receiver is busy? | Needs a bounded mailbox or ask (§08) | ✅ built in |
What your language nudges you toward
- Go makes channels and goroutines free and built in, so most Go code is CSP plus mutexes. The actor shape (§05) appears whenever one goroutine owns a resource: a connection, a cache that needs batched writes, a rate limiter.
- JavaScript has no shared-memory threads by default, so locks are rare. Use the mailbox class (§06) to serialize async work on one thing, and Workers when you need real parallelism.
- Erlang/Elixir and Akka make actors the default, with supervisors and distribution included — the strongest choice when uptime and many machines matter.
Pitfalls gallery
The unbounded mailbox
Senders never wait, so nothing slows them down. When the arrival rate is higher than the handling rate, the mailbox grows until the process runs out of memory — and every queued message dies with it. Fix: bound the mailbox (or use a buffered Go channel, which is bounded by definition), use ask so senders wait for replies, and alert on mailbox length. §08 has the simulator.
Two actors asking each other
An actor that waits for a reply inside a handler stops reading its own mailbox until the reply comes. If the other actor does the same thing back, both wait forever: a Deadlock. The cycle can be longer (A → B → C → A), and the simplest version is an actor asking itself (Quiz question 2).
1// Actor A ("orders"), handling one message:2case placeOrder:3 p := prices.Price(m.item) // ask B, then WAIT for its reply4 m.reply <- p * m.qty56// Actor B ("prices"), handling one message:7case restock:8 n := orders.Stock(m.item) // ask A, then WAIT for its reply9 setPrice(m.item, n)
Fixes: inside actors, prefer tell: send the question with your own address as the reply-to, and handle the answer as a normal message later. If you must ask, always with a timeout (§05) — a timeout turns a silent hang into an error you can see. And design so requests flow one way through your actors (orders asks prices, never the reverse).
One Go-specific catch: a "tell" into a full inbox channel still blocks. If A and B both have full inboxes and each is in the middle of telling the other, they jam exactly like the ask version. For actor-to-actor sends, use a select with a timeout (or a default case that drops or logs the message) so a handler can never wait forever on a send:
select {
case prices.inbox <- getPrice{id: m.id, item: m.item, replyTo: a.inbox}:
case <-time.After(100 * time.Millisecond):
m.reply <- errBusy // prices is overloaded: fail this request instead of jamming
}Slow or blocking work inside a handler
An actor handles one message at a time, so a 2-second database call inside a handler means every message behind it waits 2 seconds — even a quick "what's your balance?". That's Head-of-line blocking.
case report:
rows, err := db.Query(m.sql) // 2 s — the whole mailbox waits
m.reply <- summarize(rows, err)case report:
snapshot := a.copyState() // quick: just the data the job needs
go func() { // the slow part runs elsewhere…
m.reply <- buildReport(snapshot)
}() // …and the actor goes straight back to its mailbox(Here the actor keeps its state in struct fields, a.something, rather than in a loop variable as in §05 — same rule: only the actor's goroutine touches them.) The fixed version hands a copy of the state to a helper goroutine, so the actor's private state is still touched only by the actor. If the result should change the actor's state, the helper sends it back as a message to the actor's inbox. One side effect: replies to offloaded requests can now come back out of mailbox order — a quick request sent later may be answered before the slow report. In JavaScript the same applies: an await inside the Actor handler from §06 holds up the whole queue — that's the point when you need it, and a cost when you don't.
Sending a pointer to your state
Erlang copies every message, and postMessage copies too. Go channels and the JS Actor class pass references: send a map, slice or object and both sides now hold the same data. If the actor sends out a pointer to its private map, the "only the owner touches it" rule is broken and the races from §02 are back.
case getAll:
m.reply <- a.items // the caller now shares the actor's map!case getAll:
m.reply <- maps.Clone(a.items) // send a copy (import "maps", Go 1.21+)maps.Clone makes a shallow copy: a new map with the same keys and values. If the values are themselves pointers or slices, those are still shared — copy them too if the receiver might change them. Same rule as Channels pitfall #8: after you send something, treat it as handed over.
One overloaded actor for everything
An actor is a serial queue. An actor that every request has to go through is called a hot actor. One global counter actor that every request must message is a single-file line that can't use more than one CPU core, however many you have. Fix: split by key — one actor per account, per room, per user — so unrelated work runs in parallel. And if all you need is a counter or a small cache, use an atomic operation or a mutex; an actor is overkill.
Forgetting that a restart loses state
A supervisor restarts an actor with fresh state (§07). If a chat room's messages only lived in the actor's memory, they're gone after a crash. Fix: write anything you can't afford to lose to durable storage before acknowledging it, and rebuild the actor's state from there when it starts.
Messages sent into the void
Sending to an actor that has died doesn't fail — in Erlang the message is silently dropped; in Akka it becomes a "dead letter"; in Go, if nobody reads the inbox channel anymore, the sender blocks forever once the buffer is full. Fix: watch actors you depend on (monitors in Erlang, watch in Akka, ctx.Done() in Go), and put timeouts on every ask.
Check yourself
Predict the answer before you click. Where to look if you miss one: Q1 in §03 and §08, Q2 in §05 and §10, Q3 in §04 and §07, Q4 in §06, Q5 in §07, Q6 in §09.
// An account actor with balance 40.
// ONE sender does, in this order:
acct.send({ type: "deposit", amount: 50 });
acct.send({ type: "withdraw", amount: 80 });
// (withdraw is refused if balance < amount)// inside the account's loop (the only goroutine reading a.inbox)
case deposit:
bal += m.amount
log.Println("new balance:", a.Balance()) // Balance() from §05# Elixir. counter is busy with a slow message right now.
send(counter, {:add, 1})
IO.puts("next line")const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
const logger = new Actor(async (msg) => { // the Actor class from §06
console.log("start", msg);
await sleep(10);
console.log("end", msg);
});
logger.send("a");
logger.send("b");chat supervisor, strategy: rest_for_one
children, in start order: lobby, games, music
→ games crashesJob: read 1 million image files, resize each,
then upload. No state needs to be kept between images.Cheat sheet
make(chan T, 1) in the request. JS: a promise's resolve. Always with a timeout.| I want to… | Use |
|---|---|
| One owner for a stateful thing (account, room, connection) | An actor: Go goroutine + inbox channel (§05), JS Actor class (§06), GenServer, Akka actor |
| Stream data through stages | Channels and a pipeline (Channels §06) |
| Spread work over interchangeable workers | A worker pool on one channel (Channels §07) |
| Get an answer from an actor | Ask with a reply channel / promise + timeout (§05) |
| Run JS in parallel without shared memory | A Web Worker / worker_threads + postMessage (§06) |
| Recover from unexpected crashes automatically | Supervision tree (Erlang/Elixir, Akka/Pekko) (§07) |
| Protect a tiny shared counter or map | A mutex or atomic, not an actor (Channels §10) |