Dispatcher-Tuning
Der Dispatcher entscheidet, wann Actor-Nachrichten auf der JavaScript-Event-Loop laufen. Drei Formen werden ausgeliefert:
| Dispatcher | Schedult via | Passt zu |
|---|---|---|
MicrotaskDispatcher | queueMicrotask | CPU-tight; kein I/O. |
ImmediateDispatcher (Default) | setImmediate / setTimeout(0) | HTTP-Server + gemischtes I/O. |
ThroughputDispatcher | setImmediate mit N-dann-yield | Batch-Verarbeitung. |
Der Default — ImmediateDispatcher — passt für die meisten Apps.
Diese Seite behandelt, wann er nicht passt und wie du eine bessere
Wahl triffst.
Default-Verhalten
Abschnitt betitelt „Default-Verhalten“const system = ActorSystem.create('my-app');// ↑ nutzt per Default ImmediateDispatcherActor-Nachrichten laufen via setImmediate, das zwischen jeder
Nachricht yieldet — damit I/O-Callbacks (HTTP-Handler,
Broker-Nachrichten, Timer-Feuer) natürlich verschränken.
Für HTTP-Server + Broker-Actor-Cluster (der häufige Fall) ergibt das gute HTTP-Latenz auf Kosten von etwas höherem Per-Message-Overhead.
Symptom: hohe HTTP-Latenz unter Actor-Last
Abschnitt betitelt „Symptom: hohe HTTP-Latenz unter Actor-Last“P99-HTTP-Response-Time ist 200ms; Actors verarbeiten ZehntausendeNachrichten/sec. Die Actor-Arbeit ist nicht das Problem — sonderndass HTTP-Requests nicht drankommen.Ursache: Ein Actor (oder eine Gruppe Actors) verarbeitet Nachrichten so schnell, dass HTTP-Handler auf ihren Turn warten.
Fix: Beim ImmediateDispatcher (Default) bleiben und das
Batch-Budget des beschäftigten Actors senken:
import { ActorOptions } from 'actor-ts';
const heavyOptions = ActorOptions.create().withThroughput(4);
const heavyActor = system.spawnAnonymous(HeavyWorker, heavyOptions);Der schwere Actor verarbeitet 4 Nachrichten, yieldet, lässt HTTP aufholen, verarbeitet 4 weitere. HTTP-Latenz sinkt; der Durchsatz des schweren Actors fällt nur so weit, wie der kleinere Batch ihn kostet.
Symptom: niedriger Actor-Durchsatz, niedrige CPU-Nutzung
Abschnitt betitelt „Symptom: niedriger Actor-Durchsatz, niedrige CPU-Nutzung“Das Actor-System macht 1000 msg/sec auf einer idle CPU. Profilzeigt Zeit in setImmediate verbracht.Ursache: ImmediateDispatcher hat Per-Message-Overhead durch
das Yield zur Event Loop bei jeder Nachricht. Für Tight-Loop-CPU-Arbeit
ohne I/O ist das Verschwendung.
Fix: MicrotaskDispatcher nutzen:
import { ActorOptions, MicrotaskDispatcher } from 'actor-ts';
const cpuOptions = ActorOptions.create().withDispatcher(new MicrotaskDispatcher());
const cpuActor = system.spawnAnonymous(CpuIntensive, cpuOptions);Microtasks umgehen die Event Loop, ~50× schnelleres Scheduling. Caveat: Ein CPU-tighter Actor auf Microtask kann I/O aushungern (Netzwerk-Reads, Timer). Nur nutzen, wenn:
- Der Actor das System nicht mit HTTP-Traffic teilt (Compute-Only-Worker).
- Der Actor selbst nicht
awaitauf I/O macht (rein CPU).
ThroughputDispatcher-Optionen
Abschnitt betitelt „ThroughputDispatcher-Optionen“new ThroughputDispatcher(100); // queued actor turns per tickDer Konstruktor ist positional — throughput zuerst, eine
optionale Dispatcher-id als zweites:
throughput— Turns, die pro Tick abgearbeitet werden, über alle Actors hinweg, die sich diesen Dispatcher teilen. Keine Nachrichten, und nicht pro Actor: Jeder Turn gehört einem anderen Actor, weil ein Actor immer nur einen Turn gleichzeitig eingereiht haben kann. Höher = mehr Durchsatz, schlechtere I/O-Verschränkung. Übliche Werte: 10-1000. Default ist 16.- Zwischen Batches yieldet er immer via
setImmediate(mit Fallback aufsetTimeout(0), wosetImmediatenicht verfügbar ist), damit I/O und Timer verschränken. Das ist nicht konfigurierbar.
Für einen Batch-Prozessor mit vielen Actors, die Broker-Nachrichten
verarbeiten: throughput: 200 ist ein vernünftiger Startpunkt.
Per-Actor-Throughput
Abschnitt betitelt „Per-Actor-Throughput“Wie viele Nachrichten ein Actor pro Turn vor dem Yield abarbeitet, ist eine von allem am Dispatcher getrennte Einstellung:
import { ActorOptions } from 'actor-ts';
const bulkOptions = ActorOptions.create().withThroughput(64);
const bulk = system.spawnAnonymous(BulkProcessor, bulkOptions);Systemweit heißt dieselbe Stellschraube actor-ts.actor.throughput:
actor-ts { actor { throughput = 64 }}Die Präzedenz ist die übliche — withThroughput() schlägt HOCON
schlägt den eingebauten Default von 16.
Das ist die Einstellung, die den Scheduling-Overhead für einen
beschäftigten Actor beseitigt. Jede Nachricht kostete früher einen
vollen setImmediate-Roundtrip (~2,4 µs), unabhängig von jeder
Dispatcher-Einstellung; das Batching amortisiert ihn über den
gesamten Batch, was bei einer tell-getriebenen Last grob 2-3×
wert ist.
Der Trade-off ist Fairness, und zwar direkt: Ein Batch läuft ohne
Yield bis zu seinem Budget, also ist Budget × Handler-Zeit die
Verzögerung, die jeder Timer, jeder Socket-Read und jeder andere
Actor sehen kann. Senke es Richtung 1 für einen Actor mit langsamem
Handler, der sich ein System mit latenzsensitiver Arbeit teilt; erhöhe
es für einen Actor mit kurzem Handler, der ein Durchsatz-Flaschenhals
ist. Ein Batch endet immer vorzeitig bei leerer Mailbox, bei Stop oder
Suspend und bei einem leerlaufenden Throttle-Bucket — das Budget ist
also eine Obergrenze, nie eine Zusage.
Was eine Nachricht kostet, wenn niemand zusieht
Abschnitt betitelt „Was eine Nachricht kostet, wenn niemand zusieht“Wissenswert, bevor du irgendetwas tunst, weil es den Maßstab setzt, an dem alles andere gemessen wird.
Auf dem Default-Dispatcher dominiert der Scheduling-Roundtrip alles
andere auf dem Nachrichtenpfad — rund 2,4 µs gegenüber einer rohen
Mailbox-Operation von einigen zehn Nanosekunden. Deshalb ist
Per-Actor-Throughput die Einstellung mit dem
größten Effekt auf eine tell-getriebene Last, und deshalb bewegt eine
schnellere Queue oder ein schlankerer Handler die Zahl weit weniger, als
es sich anfühlt.
Darunter liegt der Per-Message-Overhead des Frameworks selbst, mit Metriken und Tracing aus. Das waren früher vier Extension-Registry-Lookups, vier Metrik-Label-Objekte, gebaut für eine Registry, die sie verwirft, ein Closure, ein Wegwerf-Array und zwei Clock-Reads — pro Nachricht, in jedem System, egal ob irgendetwas beobachtet wurde. #411 hat all das entfernt; übrig bleiben eine Handvoll Feldzugriffe und Null-Checks. Beide Extensions lassen sich weiterhin zur Laufzeit einschalten, und eine Nachricht ist entweder ganz oder gar nicht instrumentiert — die Handles werden einmal pro Nachricht aufgelöst statt an jedem Instrumentierungspunkt.
Wenn du Allokationsdruck statt Latenz verfolgst, isoliert
benchmarks/memory/receive-path.ts genau das, und sein Header erklärt,
warum sich der Effekt als Durchsatz und nicht als Heap zeigt.
Per-Actor-Dispatcher
Abschnitt betitelt „Per-Actor-Dispatcher“import { ActorOptions, MicrotaskDispatcher } from 'actor-ts';
const computeOptions = ActorOptions.create().withDispatcher(new MicrotaskDispatcher());
const heavy = system.spawnAnonymous(BulkProcessor, computeOptions);
const httpHandler = system.spawn( HttpHandler, // → nutzt den ImmediateDispatcher-Default des Systems);Frei mischen: Ein reiner Compute-Actor kann auf Microtasks laufen, während HTTP-Handler auf dem Default bleiben.
Wofür ein Per-Actor-Dispatcher nicht taugt, ist Batching. Ein
ThroughputDispatcher für einen einzigen Actor arbeitet eine Queue
ab, die nie mehr als dessen einen ausstehenden Turn hält — sein
Budget ist also unerreichbar. Nutze stattdessen
withThroughput(). Ein
ThroughputDispatcher lohnt sich, wenn eine Gruppe von Actors ihn
sich teilt.
Systemweiter Dispatcher
Abschnitt betitelt „Systemweiter Dispatcher“const actorSystemOptions = ActorSystemOptions.create().withDispatcher(new ThroughputDispatcher(100));const system = ActorSystem.create('my-app', actorSystemOptions);Override den Default für jeden Actor, der nichts anderes spezifiziert. Nützlich für Batch-only-Systeme ohne HTTP-Traffic.
Zwei Stock-Metriken teilen die Latenz einer Nachricht genau dort, wo sie angenommen wird:
actor_mailbox_wait_seconds — in der Queue, wartet auf ihren Zugactor_message_handler_seconds — innerhalb von onReceiveLies sie zusammen; das Paar sagt dir, ob Dispatcher-Tuning überhaupt helfen kann:
- Wait-p99 hoch, Handler-p99 niedrig — der Actor ist in Ordnung, die Nachrichten liegen in der Queue. Dispatcher-Tuning hilft: erhöhe das Per-Actor-Throughput-Budget, damit jeder Zug mehr Rückstau abbaut, oder nimm den Actor von einem Dispatcher herunter, den er sich mit langsamer Arbeit teilt.
- Handler-p99 hoch — der Handler selbst ist die Kosten. Keine Dispatcher-Einstellung macht ihn schneller; das ist ein Code- oder Downstream-Problem, und ein höheres Budget macht die Latenz für alles, was sich die Runtime teilt, schlechter.
Die Queueing-Verzögerung musste früher aus dem Abstand zwischen p50 und p99 des Handler-Histogramms erschlossen werden, was sie mit der Varianz der Arbeit selbst vermischte. Sie wird jetzt direkt gemessen.
Das Gauge actor_mailbox_size unter Last zeigt, ob Actors mitkommen.
Dauerhaft wachsende Tiefe = entweder ein langsamer Handler oder ein
fehlkonfigurierter Dispatcher. Es ist das spätere der beiden Signale
— eine Serie entsteht erst ab 10 000 wartenden Nachrichten, während die
Wartezeit steigt, sobald ein Actor zurückfällt. actor_mailbox_depth
füllt diese Lücke: ein Histogramm ohne Labels, einmal pro Zustellung
beobachtet, sodass ein Ausschlag, der nie 10 000 erreicht, seinen
Ausläufer dennoch zeigt.
Ist der Dispatcher selbst die Queue?
Abschnitt betitelt „Ist der Dispatcher selbst die Queue?“Beide Metriken oben betreffen die Mailbox eines Actors. Eine Nachricht kann auch eine Ebene höher warten — im Dispatcher, nachdem die Zelle einen Zug angefordert hat und bevor dieser Zug beginnt:
actor_dispatcher_queue_delay_seconds{dispatcher="..."}Im Ruhezustand ist das eine einzige Scheduling-Übergabe, Mikrosekunden. Wenn es steigt, ist weder der Actor das Problem noch seine Mailbox: Züge stauen sich hinter anderen Zügen, und genau dafür ist Dispatcher-Tuning da.
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.Neben dem Mailbox-Paar gelesen vervollständigt es die Diagnose:
- Delay-p99 hoch — die Züge konkurrieren. Entweder gibt etwas auf
diesem Dispatcher nicht ab (ein
ThroughputDispatchermit zu hohem Budget oder ein blockierender Handler), oder zu viele Actors teilen ihn sich. Teile den Dispatcher auf, oder senke sein Throughput, damit er häufiger abgibt. - Delay-p99 niedrig, Mailbox-Wait-p99 hoch — die Züge starten prompt,
und die eigene Queue eines Actors ist der Rückstau. Erhöhe
withThroughputdieses Actors, damit jeder Zug mehr abbaut, statt am Dispatcher zu drehen.
Es gibt bewusst kein dispatcher_saturation_ratio — ein 0–1-Anteil
„beschäftigt” lässt sich auf allen drei unterstützten Laufzeitumgebungen
nicht ehrlich berechnen, und
Stock-Metriken
hält die Messungen hinter dieser Entscheidung fest. Der
MicrotaskDispatcher ist der eine Fall, in dem eine niedrige
Verzögerung kein Beleg für Luft ist — aus dem dort genannten Grund.
Heuristiken
Abschnitt betitelt „Heuristiken“HTTP-Server + Actors → ImmediateDispatcher (Default)Compute-heavy + kein HTTP → MicrotaskDispatcherBatch-Verarbeitung, viele Actors → ThroughputDispatcher (throughput 100-500)Ein Actor ist der Flaschenhals → Default-Dispatcher + withThroughput(64...256)Gemischt: schwerer Actor + HTTP → Default-Dispatcher + withThroughput(2...8) auf dem schweren ActorWohin als nächstes
Abschnitt betitelt „Wohin als nächstes“- Dispatcher — die konzeptuelle Referenz.
- Mailbox-Sizing — der komplementäre Knopf.
- Stock-Metriken — die Per-Actor-Performance-Metriken.
