jsrosetta

Async

Concurrency: Threads and Child Processes

How Node.js's worker_threads/child_process.fork() compare to goroutines (Go), std::thread (Rust), TaskGroup (Swift), and thread/ProcessBuilder (Java).

Minimum versions
Node.js ≥ 12.20Go ≥ 1.25Rust ≥ 1.71Swift ≥ 5.7Java ≥ 25
Verified on
Node.js 24.12.0Go 1.27.1Rust 1.98.1Swift 6.2.4Java 25.0.4.1

Node.js code is an ES module: save it as .mjs or set "type": "module" in package.json.

Two different problems: splitting CPU-bound work across threads to run in parallel, and isolating a task in its own process. Node.js solves them with worker_threads and child_process.fork(). Go, Rust, and Java use goroutines/threads for the first one; Swift uses TaskGroup — the same idea as async let from the async/await post, but taking a dynamic list of work instead of a fixed handful of variables. None of the other four languages have a safe fork() for process isolation either: their runtimes are inherently multithreaded (a goroutine scheduler, an async thread pool, and so on), so a raw fork() only duplicates the calling thread, leaving every other lock and thread in an inconsistent state inside the child. Re-executing the binary/process itself is the safe substitute in all four.

Splitting CPU-bound work across threads (worker_threads vs goroutine)

import { Worker, isMainThread, parentPort, workerData } from "node:worker_threads";
import { fileURLToPath } from "node:url";
 
const RANGE_END = 50_000_000;
const WORKER_COUNT = 4;
 
function sumRange(start, end) {
  let total = 0;
  for (let i = start; i < end; i++) {
    total += i;
  }
  return total;
}
 
async function main() {
  const filename = fileURLToPath(import.meta.url);
  const chunk = Math.ceil(RANGE_END / WORKER_COUNT);
 
  const partials = await Promise.all(
    Array.from({ length: WORKER_COUNT }, (_, i) => {
      const start = i * chunk;
      const end = Math.min(start + chunk, RANGE_END);
      return new Promise((resolve, reject) => {
        const worker = new Worker(filename, { workerData: { start, end } });
        worker.on("message", resolve);
        worker.on("error", reject);
      });
    }),
  );
 
  const sum = partials.reduce((total, partial) => total + partial, 0);
  console.log("sum:", sum); // → sum: 1249999975000000
}
 
if (isMainThread) {
  main();
} else {
  const { start, end } = workerData;
  parentPort.postMessage(sumRange(start, end));
}

Isolating a task in its own process (fork vs re-exec)

import { fork } from "node:child_process";
import { fileURLToPath } from "node:url";
 
if (process.send) {
  // running as the forked child
  process.once("message", ({ numbers }) => {
    const results = numbers.map((n) => n * n);
    process.send({ results });
    process.disconnect();
  });
} else {
  // running as the parent
  const child = fork(fileURLToPath(import.meta.url));
  const numbers = [1, 2, 3, 4, 5];
 
  child.once("message", ({ results }) => {
    console.log("squares:", results.join(", ")); // → squares: 1, 4, 9, 16, 25
    console.log("sum:", results.reduce((a, b) => a + b, 0)); // → sum: 55
  });
 
  child.send({ numbers });
}

Key differences

Node.js Go Rust Swift Java
Unit of parallelism Worker (separate V8 isolate) goroutine std::thread Task (inside a TaskGroup) Thread (platform/virtual)
Actual OS threads running 1 thread per worker up to GOMAXPROCS shared threads 1 OS thread per thread multiplexed onto the global executor 1 thread per platform thread
Process isolation child_process.fork() re-executes its own binary re-execs itself (Command::new(current_exe)) Process (Foundation) re-exec ProcessBuilder re-exec

Reference: github.com/miguelmota/golang-for-nodejs-developers#concurrency