Skip to content
academia.sh

Lesson 12 / 20

Cluster and Worker Threads

A process cluster sharing the same port, a worker thread's memory model, which kind of work each approach fits, and the measured difference in blocking.

Contents

The previous lesson covered starting a child process and stated its cost: each process carries its own memory and its own runtime instance, and every message between them is copied.

This lesson examines two special forms of it. The first is a cluster of processes sharing the same port, and its purpose is genuinely handling multiple requests at once on a multi-core machine. The second is threads running inside the same process that can share memory; their purpose is moving an expensive computation off the main thread.

Why One Process Is Not Enough

The limit measured in the event loop lesson becomes binding here. A single process handles requests on a single thread running on a single core. For an I/O-heavy service this is enough: the process spends most of its time waiting in the poll phase, and one core is enough for dozens of concurrent connections.

The picture changes once part of the work is CPU-bound. How many independent execution units the machine has can be asked:

// cores.mjs
import os from 'node:os';
console.log('available parallelism:', os.availableParallelism());
node cores.mjs
available parallelism: 12

This number varies by machine and by the limits granted to the process; it was taken on a sample run. A single process uses only one of these units.

A Process Cluster

The node:cluster module sets up a primary process and several worker processes. Workers are separate processes forked by the primary; they share the same listening socket.

// cluster.mjs
import cluster from 'node:cluster';
import { createServer } from 'node:http';

const WORKER_COUNT = 2;

if (cluster.isPrimary) {
  console.log(`primary process ${process.pid}, starting ${WORKER_COUNT} workers`);
  for (let i = 0; i < WORKER_COUNT; i += 1) cluster.fork();

  cluster.on('exit', (worker, code, signal) => {
    console.log(`worker ${worker.process.pid} exited (code ${code}, signal ${signal}); starting a replacement`);
    cluster.fork();
  });
} else {
  createServer((request, response) => {
    response.writeHead(200, { 'content-type': 'text/plain' });
    response.end(`request handled by worker: ${process.pid}\n`);
  }).listen(8791, '127.0.0.1', () => {
    console.log(`worker ${process.pid} listening`);
  });
}

Running the server in one terminal and sending four requests from another:

node cluster.mjs
for i in 1 2 3 4; do curl -s http://127.0.0.1:8791/; done
primary process 81263, starting 2 workers
worker 81265 listening
worker 81266 listening
request handled by worker: 81265
request handled by worker: 81266
request handled by worker: 81265
request handled by worker: 81266

Process ids change on every run; the order in which the two workers start listening also changes. What is fixed is that requests get handled by two different processes — an even, alternating distribution like the example’s is not guaranteed.

Two processes being able to listen on the same port rests on the listening socket concept introduced in the How the Internet Works course. The primary process opens the socket once and hands the descriptor down to the workers; an incoming connection lands on one of the processes sharing that descriptor. There is one port number; there are multiple processes sharing it.

The callback attached to the exit event puts a replacement in place of a worker that goes down. This is the cluster’s real gain in terms of robustness: a fatal error in one worker does not stop the service entirely. The restart rate has to be limited so it does not enter an infinite loop; if a worker crashes immediately every time it starts, opening a new one just hides the failure.

The cluster’s fundamental constraint is that memory is not shared between workers. The measurement collector’s bucket map accumulates separately in each worker, and the /summary response varies depending on which worker handled the request. Clustering a stateful service requires moving its state out of the process — to a shared store.

Worker Threads

The node:worker_threads module starts a separate thread within the same process. Each thread has its own event loop and its own heap; memory is not shared by default but can be explicitly shared through a shareable buffer.

An expensive computation is handed to a thread below:

// heavy-work.mjs
import { parentPort, workerData } from 'node:worker_threads';

// A deliberately expensive computation on top of measurement values
function heavySum(iterations) {
  let total = 0;
  for (let i = 0; i < iterations; i += 1) total += Math.sqrt(i) % 1;
  return total;
}

parentPort.postMessage(Number(heavySum(workerData.iterations).toFixed(3)));
// worker-main.mjs
import { Worker } from 'node:worker_threads';
import { once } from 'node:events';

const ITERATIONS = 3e8;

setTimeout(() => console.log('2 main thread free: timer ran'), 0);
console.log('1 worker started');

const worker = new Worker('./heavy-work.mjs', { workerData: { iterations: ITERATIONS } });
const [result] = await once(worker, 'message');
console.log('3 result from worker:', result);
await once(worker, 'exit');
node worker-main.mjs
1 worker started
2 main thread free: timer ran
3 result from worker: 149989897.293

When the same computation is done on the main thread, the order changes:

// no-worker.mjs
const ITERATIONS = 3e8;

function heavySum(iterations) {
  let total = 0;
  for (let i = 0; i < iterations; i += 1) total += Math.sqrt(i) % 1;
  return total;
}

setTimeout(() => console.log('3 timer only ran now'), 0);
console.log('1 computation starting');
console.log('2 result:', Number(heavySum(ITERATIONS).toFixed(3)));
node no-worker.mjs
1 computation starting
2 result: 149989897.293
3 timer only ran now

The difference between the two outputs is the measurement itself. In the first, the timer ran while the computation was in progress; the main thread was free and could have handled an incoming request at that moment. In the second, the timer waited for the computation to finish — on a server, this means every concurrent request waits.

The result value comes out the same in both runs; the computation is deterministic and moving it to a thread does not change the result.

Which One, When

The two approaches do not substitute for each other.

Criterion Process cluster Worker thread
Isolation complete; if one crashes, the other continues weak; a fatal error can bring down the process
Memory separate; each process carries its own instance shared process, separate heap; a shareable buffer is possible
Startup cost high low
Message cost serialization + inter-process copy structured copy; large data can be transferred
Suited work multiplying request-handling capacity moving a single expensive computation off the main path

The rule for the measurement collector is this: a cluster is used when request handling capacity needs to grow, a worker thread when an expensive computation inside a single request needs to come off the main thread. The two can also be used together — each cluster worker can keep its own thread pool.

Starting a thread has a cost too: a new execution environment is set up and modules are re-evaluated. Opening a thread per request can end up costlier than the computation itself. The common solution is keeping a few pre-opened threads in a pool and distributing jobs to them — the same logic the file system calls’ pool uses.

When shared memory is needed, a buffer can be given to both threads through a SharedArrayBuffer. In that case race conditions appear, and access is coordinated with Atomics operations; every concurrency problem the single-threaded model removed comes back in through this door. Use sharing only when measurement shows it is needed.

Summary

  • A single process uses a single core; this is enough for I/O-heavy work, not for CPU-bound work.
  • A cluster sets up processes sharing the same listening socket; there is one port, multiple processes share it.
  • Cluster workers do not share memory; a stateful service has to move its state out of the process before being clustered.
  • A worker thread runs a separate event loop in the same process; once the expensive computation moves there, the main thread keeps accepting requests.
  • The selection axis is isolation versus sharing: a cluster to multiply capacity, a thread to take a single computation off the main path.

Next Step

The built-in modules topic is complete: file, path, stream, buffer, event, HTTP, child process, and multi-core usage. What comes after this is about turning these pieces into a working application. The first step is giving the measurement collector a command-line face: parsing arguments, reading from a pipeline, and writing usage text to the correct stream.

To keep your progress and take notes, Log in

My notes

Log in to take notes.

Start typing to search.

↑↓ Esc navigate · open · close