Dispatcher
Ein Dispatcher plant die Ausführung der
Nachrichtenverarbeitungs-Einheiten von Actors. Immer wenn die
Mailbox eines Actors eine Nachricht zur Verarbeitung bereit hat,
entscheidet der Dispatcher, wann die Runtime sie zieht und
onReceive ausführt.
JavaScript ist single-threaded, hat aber zwei verschränkende
Primitives für “mach das später”: die Microtask-Queue
(queueMicrotask, Promise.then) und die Macrotask-Queue
(setImmediate, setTimeout(0)). Zwischen ihnen zu wählen, ist die
Aufgabe des Dispatchers, und die Wahl hat reale Konsequenzen für
Durchsatz, Fairness und Latenz.
Die vier eingebauten Dispatcher
Abschnitt betitelt „Die vier eingebauten Dispatcher“Das Framework liefert vier aus, exponiert als Klassen, die du
instanziierst und an ActorSystem.create übergibst:
| Dispatcher | Plant via | Trade-off |
|---|---|---|
HybridDispatcher (default) | queueMicrotask, jede 64. Einheit setImmediate | Microtask-Tempo mit begrenztem Notausgang, sodass Timer und I/O weiterhin drankommen. |
MicrotaskDispatcher | queueMicrotask | Am schnellsten. Hungert I/O und Timer bei nachhaltiger Actor-Last aus — siehe die Warnung unten. |
ImmediateDispatcher | setImmediate oder setTimeout(0) | Der bisherige Default. Lässt I/O und Timer zwischen jedem Actor-Turn verschränken, zum Preis von rund 2,4 µs pro Sprung. |
ThroughputDispatcher | setImmediate mit konfigurierbarem run-N-then-yield-Budget | Wie ImmediateDispatcher, arbeitet aber bis zu throughput eingereihte Actor-Turns Back-to-Back ab, bevor es nachgibt. Balanciert Durchsatz gegen Fairness zwischen Actors. |
Warum der Default ein Hybrid ist
Abschnitt betitelt „Warum der Default ein Hybrid ist“Die beiden naheliegenden Optionen liegen jeweils in der Hälfte der Fälle falsch — und welche Hälfte es ist, hängt an einer Eigenschaft deiner Last, die du nicht in der Hand hast.
setImmediate kostet rund 2,4 µs. Ein Actor, der mit Nachrichten
geflutet wird, verteilt diese Kosten über den Batch, den er pro Turn
abarbeitet — sie verschwinden. Ein Actor, der eine Anfrage nach der
anderen beantwortet, kann gar nichts verteilen: seine Mailbox ist
zwischen zwei Nachrichten leer, er zahlt den vollen Sprung pro
Nachricht. Gemessen an einem Frage-Antwort-Volley über 10 000
Austausche waren das 8,1 µs pro Runde, davon rund 4,8 µs allein die
beiden Scheduling-Sprünge. Die eigentliche Arbeit war die kleinere
Hälfte.
queueMicrotask beseitigt diese Kosten — derselbe Volley wurde fast
viermal so schnell — und ist für sich genommen unbrauchbar, weil
Microtasks vollständig abgearbeitet werden, bevor die Event-Loop
überhaupt zu Timern oder I/O kommt. Zwei Actors, die sich Bälle
zuspielen, reihen aus einem Microtask heraus den nächsten ein, endlos,
und nichts sonst im Prozess läuft je wieder.
Der Hybrid nimmt das Tempo und begrenzt die Unfairness. Er zählt
aufeinanderfolgende Einheiten, die als Microtask geplant wurden, und
schickt bei 64 eine über setImmediate — die Event-Loop kommt dran,
danach beginnt die Zählung von vorn. Eine Einheit ist ein Actor-Turn,
selbst bis zu actor-ts.actor.throughput Nachrichten, also kommt die
Loop mindestens alle ~1 024 Nachrichten zum Zug. Der schlechteste Fall
ist exakt das, was ImmediateDispatcher immer getan hat — nie etwas
Neues.
Der Zähler sitzt am Dispatcher, nicht an den einzelnen Actors, und das ist das tragende Detail: die Microtask-Kette ist die Vereinigung über alle Actors, die auf ihm geplant sind. Ein Zähler pro Actor stünde bei zwei sich endlos zuspielenden Actors jeweils auf 1 — er würde genau in dem Fall zu niedrig zählen, für den das Budget existiert.
Einen Dispatcher wählen
Abschnitt betitelt „Einen Dispatcher wählen“import { ActorSystem, ActorSystemOptions, MicrotaskDispatcher } from 'actor-ts';
// Verwende Microtasks: maximaler Durchsatz, minimaler Scheduling-Overhead.// Wähle, wenn du gar kein I/O hast ODER I/O so selten ist, dass// Starvation keine Sorge ist.const actorSystemOptions = ActorSystemOptions.create().withDispatcher(new MicrotaskDispatcher());const system = ActorSystem.create('compute-heavy', actorSystemOptions);import { ActorSystem, ActorSystemOptions, ThroughputDispatcher } from 'actor-ts';
const actorSystemOptions = ActorSystemOptions.create().withDispatcher(new ThroughputDispatcher(50));const system = ActorSystem.create('mixed-workload', actorSystemOptions); // Laufe bis zu 50 Nachrichten pro Actor, bevor an I/O nachgegeben // wird. Default ist 16 im `ThroughputDispatcher`; es auf 50 zu // erhöhen, gewinnt beim Durchsatz auf Kosten der // HTTP-Response-Latenz.ThroughputDispatcher ist der einzige, der zum Planen eine eigene Queue
hält — die anderen reichen jede Einheit direkt an die Runtime weiter.
(Der Hybrid führt ebenfalls eine kurze Liste, aber nur solange ein Yield
unterwegs ist, damit Einheiten, die währenddessen ankommen, nicht von
später eingereihten überholt werden.) Diese Queue ist aus demselben Grund ein
RingBuffer wie eine Mailbox: sie wird
pro Tick throughput-mal von vorne abgearbeitet und hält die
ausstehende Einheit jedes Actors, ist also genau dann am tiefsten, wenn
das System am beschäftigsten ist.
Wann die Dispatcher-Wahl wichtig ist
Abschnitt betitelt „Wann die Dispatcher-Wahl wichtig ist“Für die meisten Apps ist der Default in Ordnung. Drei Situationen, in denen er es nicht ist:
- Compute-lastige Actor-Pipelines ohne I/O. Ein ETL-artiger Job,
der aus einem Journal liest, in Actors transformiert, in ein
anderes Journal schreibt — keine Live-HTTP-Requests, keine
Broker-Callbacks. Das Budget des Hybrids anzuheben
(
Dispatchers.Hybrid(1_000)) tauscht die Fairness, die du hier nicht brauchst, gegen weniger Yields. - Latenz-sensitive HTTP-Server. HTTP-Responses müssen prompt
geflusht werden; wenn deine Actors die Event-Loop monopolisieren,
wächst die Response-Latenz. Der Default gibt bereits auf ein Budget
hin nach; eine Last, die ausschliesslich Actor-Arbeit ist und harte
Latenzziele hat, kann mit
ImmediateDispatchernach jedem Turn nachgeben oder auf ein kleineres Throughput-Budget heruntergehen. - Tests, die deterministisches Ordering brauchen. Microtasks
vervollständigen vor der nächsten Macrotask,
MicrotaskDispatcherwird also für Tests bevorzugt, die “N Nachrichten senden, alle N Effekte beobachten” ohne ein Yield dazwischen erwarten. Siehe TestKit für den test-spezifischen Dispatcher.
Einen eigenen Dispatcher schreiben
Abschnitt betitelt „Einen eigenen Dispatcher schreiben“Das Dispatcher-Interface ist winzig:
interface Dispatcher { readonly id: string; execute(task: () => void | Promise<void>): void; onError?: (error: unknown, dispatcherId: string) => void;}Implementiere die ersten beiden, und du hast einen eigenen
Dispatcher; onError ist optional und wird vom Framework gefüllt
(siehe Wenn eine Arbeitseinheit
wirft). Häufige Gründe, einen zu
schreiben:
- Tracing: Wrap die Arbeitseinheit, um einen OpenTelemetry-Span anzuhängen, damit jede Actor-Nachricht ihren eigenen Trace-Kontext bekommt.
- Metering: Zähle verarbeitete Nachrichten, melde an einen Metrics-Collector.
- Per-Priority-Isolation: Halte eine “Schnellspur” für
System-Actors, während User-Actors auf einer separaten Queue
laufen. (Für die meisten Apps reichen Per-Actor-Prioritäten via
PriorityMailbox; Per-Dispatcher-Isolation ist ein fortgeschrittener Fall.)
import type { Dispatcher } from 'actor-ts';
class TracingDispatcher implements Dispatcher { readonly id = 'tracing-dispatcher'; constructor(private readonly inner: Dispatcher) {} execute(task: () => void | Promise<void>): void { this.inner.execute(() => withTraceContext(task)); }}Wrap-and-Delegate ist die übliche Form — behalte das darunterliegende Scheduling-Verhalten, füge nur Querschnitt-Anliegen hinzu.
Per-Actor-Dispatcher
Abschnitt betitelt „Per-Actor-Dispatcher“Das Actor-System hat einen Default-Dispatcher. Einzelne Actors
können ihren eigenen über ActorOptions spezifizieren:
import { ActorOptions, MicrotaskDispatcher } from 'actor-ts';
const crunchyOptions = ActorOptions.create().withDispatcher(new MicrotaskDispatcher());
const fastActor = system.spawn( Crunchy, 'crunchy', crunchyOptions,);Die meisten Apps brauchen keine Per-Actor-Dispatcher — der System-Level-Dispatcher gilt uniform. Greife dazu, wenn du eine gemischte Workload hast, bei der manche Actors Durchsatz brauchen, während andere Fairness mit I/O auf demselben Node brauchen.
Wenn eine Arbeitseinheit wirft
Abschnitt betitelt „Wenn eine Arbeitseinheit wirft“Ein Wurf aus deinem onReceive erreicht den Dispatcher nie — er geht
an die Supervision-Strategie des
Parents. Was der Dispatcher sieht, ist der seltenere Fall: ein
Fehler in der Maschinerie um den Handler herum, oder in einem Task,
der direkt an dispatcher.execute übergeben wurde. Nichts
supervidiert diese, deshalb meldet das Framework sie doppelt:
- Der System-Logger bekommt einen
error-Record — so erreicht der Fehler jeden konfigurierten Log-Sink zusammen mit allem anderen, MDC eingeschlossen. - Der Event-Stream bekommt einen
DispatcherError, mit deriddes fehlgeschlagenen Dispatchers, dercauseund derActorRef, deren Zug es war (nullfür Arbeit, die zu keinem Actor gehört).
import { Actor, DispatcherError } from 'actor-ts';
class DispatcherAlarm extends Actor<DispatcherError> { override preStart(): void { this.system.eventStream.subscribe(this.self, DispatcherError); } override onReceive(event: DispatcherError): void { this.log.error(`dispatcher ${event.dispatcherId} lost a turn`, event.cause); }}Das gilt für jeden Dispatcher, auf dem ein Actor läuft, auch für eine Per-Actor-Instanz und eine Fremdimplementierung, die das System nie sieht — das Framework fängt den Fehler an der Actor-Cell ab, bevor der Dispatcher überhaupt beteiligt ist.
console.error bleibt nur als letzte Rettung, für einen Dispatcher,
der außerhalb eines Actor-Systems benutzt wird: onError ist ungesetzt,
bis ein ActorSystem die Instanz übernimmt, und ein Fehler, der
niemanden erreicht, ist schlimmer als einer, der ein Terminal erreicht.
Ein Sink, den du selbst setzt, wird nie übernommen — das System
verdrahtet seinen eigenen nur in einen freien Slot.
Wie es weitergeht
Abschnitt betitelt „Wie es weitergeht“- Mailboxes — die Queue, aus der der Dispatcher zieht. FIFO / Bounded / Priority.
- Timer und Scheduling — actor-gebundene Timer; verwendet den Scheduler, nicht den Dispatcher.
- ActorSystem — Übergabe eines Dispatchers via Settings-Argument.
- TestKit — der test-spezifische Dispatcher, der synchron läuft für Assertion-freundliche Tests.
