Zum Inhalt springen
Deutsch

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.

Das Framework liefert vier aus, exponiert als Klassen, die du instanziierst und an ActorSystem.create übergibst:

DispatcherPlant viaTrade-off
HybridDispatcher (default)queueMicrotask, jede 64. Einheit setImmediateMicrotask-Tempo mit begrenztem Notausgang, sodass Timer und I/O weiterhin drankommen.
MicrotaskDispatcherqueueMicrotaskAm schnellsten. Hungert I/O und Timer bei nachhaltiger Actor-Last aus — siehe die Warnung unten.
ImmediateDispatchersetImmediate 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.
ThroughputDispatchersetImmediate mit konfigurierbarem run-N-then-yield-BudgetWie ImmediateDispatcher, arbeitet aber bis zu throughput eingereihte Actor-Turns Back-to-Back ab, bevor es nachgibt. Balanciert Durchsatz gegen Fairness zwischen Actors.

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.

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.

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 ImmediateDispatcher nach jedem Turn nachgeben oder auf ein kleineres Throughput-Budget heruntergehen.
  • Tests, die deterministisches Ordering brauchen. Microtasks vervollständigen vor der nächsten Macrotask, MicrotaskDispatcher wird 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.

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.

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.

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 der id des fehlgeschlagenen Dispatchers, der cause und der ActorRef, deren Zug es war (null fü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.

  • 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.