Zum Inhalt springen
Deutsch

Worker-Mesh

JavaScript ist pro Actor-System Single-Threaded. Für Parallelität innerhalb eines einzelnen OS-Prozesses lässt das Worker-Mesh des Frameworks mehrere ActorSystems laufen — eines pro Worker-Thread — die alle am selben Cluster über einen MessageChannel-Transport teilnehmen.

Hauptprozess (einzelner OS-Prozess)

ActorSystem 'main'

Hauptthread

ActorSystem 'w1'

Worker-Thread 1

ActorSystem 'w2'

Worker-Thread 2

ActorSystem 'w3'

Worker-Thread 3

Jeder ist aus Sicht des Clusters ein separater Cluster-Node — Gossip + Mitgliedschaft + Sharding gelten alle. Die Kommunikation zwischen ihnen läuft über In-Process-MessageChannel (keine Serialisierung zu Bytes, kein TCP).

Zwei Hauptszenarien:

  1. CPU-gebundene Parallelität in einem Prozess — actor-ts ist pro System Single-Threaded; Multi-Threading braucht mehrere Systeme. Worker-Mesh verteilt sie.
  2. Isolation innerhalb eines Prozesses — ein “Worker”, der ausfällt, reißt das Hauptsystem nicht mit.

Für Multi-Process-Parallelität (separate OS-Prozesse) nutze den regulären Cluster + TCP-Transport. Worker-Mesh ist speziell für den In-Process-Fall.

Broker, Kanäle und den Per-Worker-Handshake von Hand zu verdrahten (siehe Unter der Haube weiter unten) ist genau das, was WorkerCluster automatisiert. Es spawnt einen Worker-Pool aus einem Entrypoint-Modul, führt den Hello/Init/Ready-Handshake aus, registriert jeden Worker an einem gemeinsamen WorkerBroker und startet abgestürzte Worker gemäß einer restartPolicy neu. Das zugrunde liegende Worker-Primitiv wird pro Runtime gewählt — Web Workers auf Bun/Deno, node:worker_threads auf Node — sodass derselbe Code überall läuft.

// main.ts — Hauptthread
import { WorkerCluster, WorkerClusterOptions } from 'actor-ts';
const workerClusterOptions = WorkerClusterOptions.create()
.withWorkers(4)
.withBootstrap(new URL('./worker-node.js', import.meta.url))
.withSystemName('multi-core')
.withBasePort(2552);
const cluster = await WorkerCluster.spawn(workerClusterOptions);
console.log(`Spawned ${cluster.size} workers:`);
for (const address of cluster.addresses) console.log(' -', address.toString());
// ...später, beim Shutdown:
await cluster.terminate();

Jeder Worker führt das Bootstrap-Modul aus. Es ruft WorkerNode.join() auf, um den Handshake abzuschließen und seine Adresse, den System-Namen, den Transport und initData zu erhalten, und tritt dann dem Cluster bei wie jeder andere Knoten:

// worker-node.ts — läuft in jedem Worker
import { ActorSystem, Cluster, ClusterOptions, WorkerNode } from 'actor-ts';
async function main(): Promise<void> {
const context = await WorkerNode.join<{ seedAddress?: string }>();
const system = ActorSystem.create(context.systemName);
const clusterOptions = ClusterOptions.create()
.withHost(context.self.host)
.withPort(context.self.port)
.withSeeds(context.initData.seedAddress ? [context.initData.seedAddress] : [])
.withTransport(context.transport);
await Cluster.join(system, clusterOptions);
system.spawn(MyActor, 'worker');
context.ready(); // dem Hauptthread signalisieren, dass der Knoten läuft
}
void main();
OptionWas
withBootstrap(url)Worker-Entrypoint-Modul. Pflicht.
withWorkers(n | 'auto')Pool-Größe; 'auto' nutzt die Hardware-Concurrency. Default 'auto'.
withSystemName(name)ActorSystem-Name, den jeder Worker hostet. Default 'worker-cluster'.
withHostname(host)Hostname-Komponente der Adresse jedes Workers. Default 'worker'.
withBasePort(port)Port des ersten Workers; jeder weitere inkrementiert. Default 1.
withInitData(data)Payload, die jedem Worker im join()-Context zugestellt wird (initData). Default null.
withRestartPolicy(p)'always' / 'on-failure' / 'never'. Default 'on-failure'.
withReadyTimeoutMs(ms)Handshake-Timeout pro Worker. Default 10000.
withBackend(backend)Startet über dieses WorkerBackend statt über das erkannte — für eine Runtime, die die Erkennung nicht kennt, oder ein In-Memory-Fake im Test. Default: erkannt.

Das zurückgegebene WorkerCluster bietet size, addresses (die NodeAddress jedes Workers), den gemeinsamen broker und terminate(). Fehlkonfigurierte Optionen (ein falscher basePort, ein nicht-positives workers) werfen OptionsError bei spawn.

Das obige WorkerCluster übernimmt diese Verdrahtung für dich. Greife zum manuellen Weg nur, wenn du volle Kontrolle brauchst — eine eigene Topologie, einen maßgeschneiderten Handshake oder zusätzliches Per-Node-Setup.

// main.ts — Hauptthread
import { Worker } from 'node:worker_threads';
import { ActorSystem, Cluster, ClusterOptions, MessageChannelTransport, NodeAddress } from 'actor-ts';
const channel = new MessageChannel();
const w1 = new Worker('./worker.js', {
workerData: { mainPort: channel.port2 },
transferList: [channel.port2],
});
const mainAddress = new NodeAddress('main', 'main', 0);
const transport = new MessageChannelTransport(mainAddress, channel.port1);
const system = ActorSystem.create('main');
const clusterOptions = ClusterOptions.create()
.withHost('main')
.withPort(0)
.withSeeds(['main'])
.withTransport(transport);
await Cluster.join(
system,
clusterOptions,
);
// worker.js — läuft im Worker-Thread
import { parentPort, workerData } from 'node:worker_threads';
import { ActorSystem, Cluster, ClusterOptions, MessageChannelTransport, NodeAddress } from 'actor-ts';
const workerAddress = new NodeAddress('w1', 'w1', 0);
const transport = new MessageChannelTransport(workerAddress, workerData.mainPort);
const system = ActorSystem.create('w1');
const cluster2Options = ClusterOptions.create()
.withHost('w1')
.withPort(0)
.withSeeds(['main'])
.withTransport(transport);
await Cluster.join(
system,
cluster2Options,
);
// Ab hier ist w1 einfach ein weiterer Cluster-Node

Der Transport ist ein Stern, kein voll vermaschtes Mesh. Ein einzelner WorkerBroker im Hauptthread ist die Nabe: Er hält einen MessagePort pro Node und leitet jeden Frame an den Node weiter, den die to-Adresse des Envelopes benennt. Der MessageChannelTransport jedes Nodes hält einen einzelnen Port — sein eigenes Ende eines Channels zum Broker, niemals ein Array:

import { MessageChannelTransport, NodeAddress, WorkerBroker } from 'actor-ts';
// Hauptthread: ein Broker ist die Nabe für jeden Node.
const broker = new WorkerBroker();
// Ein MessageChannel pro Worker — der Broker behält das eine Ende und
// der Worker erhält das andere (übertragen via workerData).
const workerAddress = new NodeAddress('w1', 'w1', 0);
const channel = new MessageChannel();
broker.register(workerAddress, channel.port1);
// In diesem Worker hält sein Transport das einzelne ferne Ende:
const transport = new MessageChannelTransport(workerAddress, channel.port2);

Also brauchen N Worker N Channels (einen pro Worker) — nicht binomial(N,2). Der Broker leitet jeden Hop weiter, was die Verdrahtung linear hält, aber den Hauptthread zum Relay für den gesamten Node-übergreifenden Verkehr macht.

TCP-Transport: MessageChannelTransport:
- Sockets, Framing - postMessage zwischen Threads
- Serialisierte Bytes - Structured Cloning (kein JSON)
- Netzwerklatenz - Sub-Mikrosekunde
- Cross-Host - Nur derselbe Prozess

Nachrichten zwischen Worker-Systemen gehen durch Structured Clone — schneller als JSON.stringify + parse und erhalten mehr Typen (Map, Set, Date etc.).

// 4-Worker-Mesh; Sharding verteilt Entities über sie:
const startShardingOptions = StartShardingOptions.create()
.withTypeName('order')
.withEntityActor(...)
.withExtractEntityId((message) => message.id)
.withNumShards(16);
sharding.start(
startShardingOptions,
);

Der Koordinator (auf main) allokiert Shards auf die 4 Worker. CPU-gebundene Entity-Arbeit parallelisiert über Cores.

// Worker, der GPU-gebundene Jobs handhabt:
system.spawn(GpuJobActor, 'gpu-jobs');
// Abstürze in diesem Worker bleiben isoliert von main + anderen Workern

Ein abstürzender Worker reißt das Hauptsystem nicht mit — separate Event-Loops.

  • Cluster-Überblick — das Cluster-Modell, an dem das Worker-Mesh teilnimmt.
  • Transports — die Transport-Schnittstelle, die MessageChannelTransport implementiert.
  • Sharding — der Hauptnutzer der Mesh-Parallelität.