ActorSystem owns one dispatcher. When an actor receives a message, it is scheduled onto a shared ready queue; a worker pulls it off, drains up to a configurable throughput budget, and yields back so other actors get a turn.
This is the Akka / Pekko / Erlang / Orleans pattern adapted for Go: the worker count is bounded by GOMAXPROCS, independent of the actor count. Actors become units of work on a scheduler rather than units that each own a goroutine — making runtime.NumGoroutine() stable at workerCount + O(1) no matter how many actors are active.
The public API is untouched: Tell, Ask, Spawn, Actor.Receive, PreStart, PostStop, mailboxes — all unchanged. The dispatcher lives below PID.doReceive and is an implementation detail of the actor package.
Components
The schedulable contract
The worker is agnostic to the actor state machine. ready_queue.go defines:
PID and grainPID both implement runTurn. This keeps the dispatcher a reusable scheduling primitive; any future schedulable (e.g. timers, stream stages) can plug in without touching the worker loop.
Actor state machine
EachPID carries a lock-free schedState with three values:
Invariant: at most one worker holds an actor in Processing. Enforced by the Scheduled → Processing CAS at the top of runTurn; this preserves the single-threaded-per-actor execution guarantee that user code relies on.
TrySchedule reads the state before attempting the CAS. Under N-way parallel Tell to the same actor, the hot cache line is read in shared mode by N-1 losers; an unconditional CAS would force request-for-ownership traffic on every call.
Enqueue path
PID.doReceive is the unified enqueue entry point:
isControlMessage routes PoisonPill, Panicking, Pause/ResumePassivation, PanicSignal, Terminated, and SendDeadletter to the system mailbox. All other messages go to the user mailbox. AsyncRequest / AsyncResponse are not control messages — they participate in the reentrancy stash and must keep FIFO order with user traffic.
The hot path is one atomic read, one CAS, one mailbox op, and — for the first producer that wins the Idle → Scheduled transition — one global-queue push with condvar signal.
Worker turn
The worker drains system messages first, then user messages, up to the throughput budget:dispatchOnereturns aretainedflag. The reentrancy-stash path holds on to theReceiveContextbeyond the turn, so the caller must not return it to the pool in that case.finishOrReclaimcloses the enqueue/finish race. After a drained dequeue, it storesIdle, re-reads both mailboxes, and — if a concurrentdoReceiveslipped a message in between the last dequeue and the state store — attemptsTrySchedulefollowed byTakeForProcessingto reclaim ownership within the current turn.
Scheduled and is re-pushed onto the owning worker’s local ring via worker.reschedule. Yielded actors land at the tail of the local ring, behind any other scheduled actor, guaranteeing forward progress for peers.
Ready queue
readyQueue combines per-worker local rings with a shared global ring and condvar parking. The take priority is: own local → global → steal from siblings → park.
A lock-free
globalCount atomic mirrors global.size so workers can skip the mutex on the fast path when the global ring is known empty. Sibling-steal probes use the victim’s own atomic size counter to avoid acquiring every sibling’s mutex during a scan.
stealHalf moves half the victim’s contents into the thief’s ring under pointer-ordered locking (lockOrder) to avoid deadlock when two workers steal from each other simultaneously.
System-message priority
Control-plane messages cannot queue behind a user-message backlog. Every actor has two mailboxes:systemMailbox— unbounded, holds control messages. Always consulted first inside the budget loop.mailbox— the user mailbox, unchanged from the publicMailboxcontract.
PoisonPill delivered to an actor with 10,000 user messages queued shuts the actor down after at most one additional user message, not 10,000.
User-provided custom mailboxes continue to work: they sit in the mailbox slot and are not aware of the system mailbox.
Lifecycle
PoisonPill delivery is preserved during shutdown: the system-message path is drained at turn time, so actors still process their final lifecycle messages even as the system unwinds.
Observable guarantees
The dispatcher pool preserves every semantic that user code relies on:- Single-threaded actor execution. The
Scheduled → ProcessingCAS guarantees at most one worker holds an actor at a time. - FIFO within a producer. Messages
m1, m2, m3sent by the same producer to the same actor are dequeued in order. PreStart/PostStopcontract. Run exactly once per lifecycle, inside a turn on whichever worker happens to own the actor.- Reentrancy stash. Unchanged; reentrance happens within a single worker’s call stack for one actor.
- Panic recovery.
defer recover()insidedispatchOne, same as before. - Supervision. Supervisor directives run on the parent’s turn after the child’s
Panickingmessage reaches the parent’s system mailbox.
runtime.NumGoroutine(): it now returns workerCount + O(1) instead of scaling with active-actor count.
Tuning constants
The worker count is floored at 2 so work-stealing always has at least one sibling to probe. The throughput budget is chosen to amortise Go’s park/unpark cost over a meaningful batch while still bounding the blocking window a single actor can impose on its peers.
Tuning the throughput budget
The per-turn message budget is the one knob exposed to users, viaWithThroughputBudget on NewActorSystem:
Per-actor FIFO ordering and the single-threaded-per-actor execution invariant hold regardless of the budget — a yield is a pause, not a reorder.
References
- Akka Dispatchers
- Pekko Dispatchers
- Erlang/OTP scheduler
- Microsoft Orleans schedulers
- Tokio work-stealing scheduler
See also
- Architecture Overview — bird’s-eye view of the system
- Code Map — package layout and file-level responsibilities
- Design Decisions — rationale for architectural choices
- Full design document:
architecture/DISPATCHER_POOL_DESIGN.md