콘텐츠로 이동
한국어

Dispatcher tuning

이 콘텐츠는 아직 번역되지 않았습니다.

The dispatcher decides when actor messages run on the JavaScript event loop. Three shapes ship:

DispatcherSchedules viaFits
MicrotaskDispatcherqueueMicrotaskCPU-tight; no I/O.
ImmediateDispatcher (default)setImmediate / setTimeout(0)HTTP servers + mixed I/O.
ThroughputDispatchersetImmediate with N-then-yieldBatch processing.

The default — ImmediateDispatcher — is right for most apps. This page covers when it isn’t, and how to pick a better fit.

const system = ActorSystem.create('my-app');
// ↑ uses ImmediateDispatcher by default

Actor messages run via setImmediate, which yields between each message — letting I/O callbacks (HTTP handlers, broker messages, timer fires) interleave naturally.

For HTTP servers + broker-actor clusters (the common case), this gives good HTTP latency at the cost of slightly higher per-message overhead.

Symptom: high HTTP latency under actor load

Section titled “Symptom: high HTTP latency under actor load”
P99 HTTP response time is 200ms; actors are processing tens
of thousands of messages/sec. The actor work isn't the
problem — it's that HTTP requests can't get a turn.

Cause: an actor (or group of actors) is processing messages so fast that HTTP handlers wait for a turn.

Fix: stick with ImmediateDispatcher (the default) and lower the busy actor’s own batch budget:

import { ActorOptions } from 'actor-ts';
const heavyOptions = ActorOptions.create().withThroughput(4);
const heavyActor = system.spawnAnonymous(HeavyWorker, heavyOptions);

The heavy actor handles 4 messages, yields, lets HTTP catch up, handles 4 more. HTTP latency drops; throughput on the heavy actor falls off only as far as the smaller batch costs it.

Symptom: low actor throughput, low CPU usage

Section titled “Symptom: low actor throughput, low CPU usage”
The actor system is doing 1000 msg/sec on an idle CPU.
Profile shows time spent in setImmediate.

Cause: ImmediateDispatcher has per-message overhead from yielding to the event loop on every message. For tight-loop CPU work without I/O, this is wasted.

Fix: use MicrotaskDispatcher:

import { ActorOptions, MicrotaskDispatcher } from 'actor-ts';
const cpuOptions = ActorOptions.create().withDispatcher(new MicrotaskDispatcher());
const cpuActor = system.spawnAnonymous(CpuIntensive, cpuOptions);

Microtasks bypass the event loop, ~50× faster scheduling. Caveat: a CPU-tight actor on microtask can starve I/O (network reads, timers). Use only when:

  • The actor doesn’t share the system with HTTP traffic (compute-only workers).
  • The actor itself doesn’t await I/O (purely CPU).
new ThroughputDispatcher(100); // queued actor turns per tick

The constructor is positional — throughput first, an optional dispatcher id second:

  • throughput — queued turns drained per tick, across every actor sharing this dispatcher. Not messages, and not per actor: each turn belongs to a different actor, because an actor may only have one turn queued at a time. Higher = more throughput, worse I/O interleaving. Common values: 10-1000. Defaults to 16.
  • Between batches it always yields via setImmediate (falling back to setTimeout(0) where setImmediate is unavailable), so I/O and timers interleave. This isn’t configurable.

For a batch processor with many actors handling broker messages: throughput: 200 is a reasonable starting point.

How many messages one actor handles per turn before it yields is a separate setting from anything on the dispatcher:

import { ActorOptions } from 'actor-ts';
const bulkOptions = ActorOptions.create().withThroughput(64);
const bulk = system.spawnAnonymous(BulkProcessor, bulkOptions);

System-wide, the same knob is actor-ts.actor.throughput:

actor-ts {
actor {
throughput = 64
}
}

Precedence is the usual one — withThroughput() beats HOCON beats the built-in default of 16.

This is the setting that removes scheduling overhead for a busy actor. Every message used to cost a full setImmediate round trip (~2.4 µs) regardless of any dispatcher setting; batching amortises that across the whole batch, which is worth roughly 2-3× on a tell-driven workload.

The trade-off is fairness, and it is direct: a batch runs to its budget without yielding, so budget × handler time is the delay every timer, socket read and other actor can see. Lower it toward 1 for an actor with a slow handler that shares a system with latency-sensitive work; raise it for a short-handler actor that is a throughput bottleneck. A batch always ends early on an empty mailbox, a stop or suspend, and a throttle bucket that runs out — so the budget is a ceiling, never a commitment.

What a message costs when nothing is watching

Section titled “What a message costs when nothing is watching”

Worth knowing before you tune anything, because it sets the scale everything else is measured against.

On the default dispatcher the scheduling round trip dominates everything else on the message path — about 2.4 µs against a raw mailbox operation of tens of nanoseconds. That is why per-actor throughput is the setting with the largest effect on a tell-driven workload, and why a faster queue or a leaner handler moves the number far less than it feels like it should.

Below that sits the framework’s own per-message overhead with metrics and tracing off. It used to be four extension-registry lookups, four metric label objects built for a registry that discards them, a closure, a throwaway array, and two clock reads — per message, on every system, whether or not anything was observing. #411 removed all of it; what remains is a handful of field reads and null checks. Both extensions are still free to switch on at runtime, and a message is either wholly instrumented or wholly not — the handles are resolved once per message rather than at each instrumentation point.

If you are chasing allocation pressure rather than latency, benchmarks/memory/receive-path.ts is the arm that isolates this, and its header explains why the effect shows up as throughput rather than as heap.

import { ActorOptions, MicrotaskDispatcher } from 'actor-ts';
const computeOptions = ActorOptions.create().withDispatcher(new MicrotaskDispatcher());
const heavy = system.spawnAnonymous(BulkProcessor, computeOptions);
const httpHandler = system.spawn(
HttpHandler,
// → uses system's default ImmediateDispatcher
);

Mix freely: a compute-only actor can run on microtasks while HTTP handlers stay on the default.

What a per-actor dispatcher is not good for is batching. A ThroughputDispatcher given to one actor drains a queue that never holds more than that actor’s single pending turn, so its budget is unreachable — use withThroughput() instead. A ThroughputDispatcher earns its keep when a group of actors shares it.

const actorSystemOptions = ActorSystemOptions.create().withDispatcher(new ThroughputDispatcher(100));
const system = ActorSystem.create('my-app', actorSystemOptions);

Override the default for every actor that doesn’t specify otherwise. Useful for batch-only systems with no HTTP traffic.

Two stock metrics split a message’s latency at the moment it is picked up:

actor_mailbox_wait_seconds — queued, waiting its turn
actor_message_handler_seconds — inside onReceive

Read them together; the pair is what tells you whether tuning the dispatcher can help at all:

  • Wait p99 high, handler p99 low — the actor is fine and messages are sitting in the queue. Dispatcher tuning helps: raise the per-actor throughput budget so each turn drains more of the backlog, or move the actor off a dispatcher it is sharing with slow work.
  • Handler p99 high — the handler itself is the cost. No dispatcher setting makes it faster; that is a code or downstream problem, and raising the budget makes latency worse for everything sharing the runtime.

Queueing delay used to have to be inferred from the spread between p50 and p99 of the handler histogram, which conflated it with variance in the work itself. It is now measured directly.

The actor_mailbox_size gauge under load shows whether actors are keeping up. Persistently growing depth = either a slow handler or a misconfigured dispatcher. It is the later signal of the two — a series only exists above 10 000 queued messages, where wait starts climbing as soon as an actor falls behind. actor_mailbox_depth fills that gap: a label-free histogram observed once per delivery, so a burst that never reaches 10 000 still shows its tail.

Both metrics above are about an actor’s mailbox. A message can also wait one level up — in the dispatcher, after the cell has asked for a turn and before that turn starts:

actor_dispatcher_queue_delay_seconds{dispatcher="..."}

At rest this is a single scheduling hand-off, microseconds. When it climbs, the actor is not the problem and neither is its mailbox: turns are queued behind other turns, which is what dispatcher tuning is for.

histogram_quantile(0.99,
rate(actor_dispatcher_queue_delay_seconds_bucket[5m]))
# Per dispatcher. A high p99 on one and not the others tells you which
# pool to split, and which actors to move off it.

Reading it beside the mailbox pair completes the diagnosis:

  • Delay p99 high — turns are competing. Either something on this dispatcher is not yielding (a ThroughputDispatcher with too high a budget, or a handler that blocks), or too many actors share it. Split the dispatcher, or lower its throughput so it yields more often.
  • Delay p99 low, mailbox wait p99 high — the turns start promptly and one actor’s own queue is the backlog. Raise that actor’s withThroughput so each turn drains more, rather than touching the dispatcher.

There is deliberately no dispatcher_saturation_ratio — a 0–1 busy fraction cannot be computed honestly on all three supported runtimes, and Stock metrics records the measurements behind that decision. MicrotaskDispatcher is the one case where a low delay is not evidence of headroom, for the reason noted there.

HTTP server + actors → ImmediateDispatcher (default)
Compute-heavy + no HTTP → MicrotaskDispatcher
Batch processing, many actors → ThroughputDispatcher (throughput 100-500)
One actor is the bottleneck → default dispatcher + withThroughput(64...256)
Mixed: heavy actor + HTTP → default dispatcher + withThroughput(2...8) on the heavy actor