コンテンツにスキップ
日本語

Worker mesh

このコンテンツはまだ日本語訳がありません。

JavaScript is single-threaded per actor system. For parallelism within one OS process, the framework’s worker mesh runs multiple ActorSystems — one per worker thread — all participating in the same cluster via a MessageChannel transport.

Main process (single OS process)

ActorSystem 'main'

main thread

ActorSystem 'w1'

Worker thread 1

ActorSystem 'w2'

Worker thread 2

ActorSystem 'w3'

Worker thread 3

Each is a separate cluster node to the cluster’s view — gossip + membership + sharding all apply. Communication between them goes via in-process MessageChannel (no serialization to bytes, no TCP).

Two main scenarios:

  1. CPU-bound parallelism in one process — actor-ts is single-threaded per system; multi-threading needs multiple systems. Worker mesh distributes them.
  2. Isolation within one process — a “worker” failing doesn’t take down the main system.

For multi-process parallelism (separate OS processes), use regular cluster + TCP transport. Worker mesh is specifically for the in-process case.

Wiring the broker, channels, and per-worker handshake by hand (see Under the hood below) is exactly what WorkerCluster automates. It spawns a pool of workers from one entrypoint module, runs the hello/init/ready handshake, registers each worker with a shared WorkerBroker, and restarts crashed workers per a restartPolicy. The underlying worker primitive is picked per runtime — Web Workers on Bun/Deno, node:worker_threads on Node — so the same code runs everywhere.

// main.ts — main thread
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());
// ...later, on shutdown:
await cluster.terminate();

Each worker runs the bootstrap module. It calls WorkerNode.join() to complete the handshake and receive its address, system name, transport, and initData, then joins the cluster like any other node:

// worker-node.ts — runs inside each 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(); // tell the main thread this node is up
}
void main();
OptionWhat
withBootstrap(url)Worker entrypoint module. Required.
withWorkers(n | 'auto')Pool size; 'auto' uses hardware concurrency. Default 'auto'.
withSystemName(name)ActorSystem name each worker hosts. Default 'worker-cluster'.
withHostname(host)Hostname component of each worker’s address. Default 'worker'.
withBasePort(port)Port of the first worker; each subsequent worker increments. Default 1.
withInitData(data)Payload delivered to every worker’s join() context (initData). Default null.
withRestartPolicy(p)'always' / 'on-failure' / 'never'. Default 'on-failure'.
withReadyTimeoutMs(ms)Per-worker handshake timeout. Default 10000.
withBackend(backend)Spawn through this WorkerBackend instead of the detected one — a runtime auto-detection does not know, or an in-memory fake in a test. Default: detected.

The returned WorkerCluster exposes size, addresses (the NodeAddress of each worker), the shared broker, and terminate(). Misconfigured options (a bad basePort, a non-positive workers) throw OptionsError at spawn.

The WorkerCluster above does this wiring for you. Reach for the manual path only when you need full control — a custom topology, a bespoke handshake, or extra per-node setup.

// main.ts — main thread
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 — runs in the 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,
);
// From here on, w1 is just another cluster node

The transport is a star, not a full mesh. A single WorkerBroker on the main thread is the hub: it holds one MessagePort per node and forwards each frame to the node named by the envelope’s to address. Every node’s MessageChannelTransport holds a single port — its own end of one channel to the broker, never an array:

import { MessageChannelTransport, NodeAddress, WorkerBroker } from 'actor-ts';
// Main thread: one broker is the hub for every node.
const broker = new WorkerBroker();
// One MessageChannel per worker — the broker keeps one end and
// the worker receives the other (transferred via workerData).
const workerAddress = new NodeAddress('w1', 'w1', 0);
const channel = new MessageChannel();
broker.register(workerAddress, channel.port1);
// Inside that worker, its transport holds the single far end:
const transport = new MessageChannelTransport(workerAddress, channel.port2);

So N workers need N channels (one per worker) — not binomial(N,2). The broker relays every hop, which keeps the wiring linear but makes the main thread the relay for all cross-node traffic.

TCP transport: MessageChannelTransport:
- Sockets, framing - postMessage between threads
- Serialized bytes - Structured cloning (no JSON)
- Network latency - Sub-microsecond
- Cross-host - Same process only

Messages between worker systems go through structured clone — faster than JSON.stringify + parse, and preserves more types (Map, Set, Date, etc.).

// 4-worker mesh; sharding distributes entities across them:
const startShardingOptions = StartShardingOptions.create()
.withTypeName('order')
.withEntityActor(...)
.withExtractEntityId((message) => message.id)
.withNumShards(16);
sharding.start(
startShardingOptions,
);

The coordinator (on main) allocates shards to the 4 workers. CPU-bound entity work parallelizes across cores.

// Worker that handles GPU-bound jobs:
system.spawn(GpuJobActor, 'gpu-jobs');
// Crashes within this worker stay isolated from main + other workers

A worker crashing doesn’t take down the main system — separate event loops.

  • Cluster overview — the cluster model worker-mesh participates in.
  • Transports — the transport interface MessageChannelTransport implements.
  • Sharding — the primary consumer of mesh parallelism.