Processes and threads — split or share?
In Shell scripting and environment variables, we noted that exported values reach the child but changes in the child don't return to the parent. This isn't a convention — it's because the child is a separate process, and what's passed is a copy of the value.
Threads are the opposite: they share the same memory. Sharing removes handoff cost, and sharing changes how things break. This chapter looks at the dividing line.
What you'll learn in this chapter
- What processes and threads split and what they share
- The mechanism where child processes are created as copies of the parent, and why environment variable copies are one-way
- That each stage of a pipe runs concurrently
- How race conditions arise on shared memory and how to fix them
- Distinguishing CPU-bound from wait-bound slowness
- Terminating with signals, and stopping without leaving work behind
Reading standard I/O from Shell basics and parent-child relations from Shell scripting and environment variables first ties the examples together. Event loop covers the side of handling wait time on a single thread.
What this chapter does not cover
| Topic | See |
|---|---|
Shell-side operations like export and execution methods | Shell scripting and environment variables |
| Mechanism for handling wait time on one thread | Event loop |
| How many queue workers to run and how to monitor them | Laravel API Development Guide — Queues in production |
| Machine count and placement settings for containers | Practical AWS Guide — ECS Part 2 |
| The decision to add machines itself | Scaling directions |
The container and the flow inside
A process is a container for resources; a thread is a flow of execution running inside it. One process has at least one thread and can have more.
| Process | Threads inside one process | |
|---|---|---|
| Memory space | Separate. Can't see each other's variables | Shared. Same variables read and written |
| File descriptors | Separate (copied at creation) | Shared |
| Call stack | Separate | Per-thread separate. Not shared |
| Environment variables | Separate (copied at creation) | Point to the same process's environment. Runtime may copy |
| Creation cost | High | Low |
| One abnormal exit | Others survive | Whole process may die. Because memory is shared |
The file-descriptor row has a follow-up. What gets copied is the table of numbers; the open file each copied number points at is the same one for parent and child. They share the read/write offset too, so the parent's position advances by whatever the child wrote. The pipe seen later also works by handing children numbers that point at the same thing.
Call stacks being per-thread ties directly to the recursion depth limit in Recursion and divide-and-conquer. The stackable frame count is per-thread capacity.
The condition on the environment-variable row is because languages and runtimes may overlay the table. The process's environment is one, but for instance Node.js workers get a copy of process.env by default, so worker-side writes don't return to the parent. This table is about the OS layer; how your runtime shows it is a separate check.
The last row matters most in practice. Keeping them separate avoids collateral damage; sharing removes handoff. Which to take is this chapter's theme.
Child processes are created as copies
New processes aren't made from nothing — they are made by copying the current process. POSIX's fork defines it:
The new process (child process) shall be an exact copy of the calling process (parent process) except as detailed below
Right after copying, exec replaces the copy with a different program:
The exec family of functions shall replace the current process image with a new process image. ... There shall be no return from a successful exec, because the calling process image is overlaid by the new process image.
The environment copy reaches the child at the fork, and carries over at the replacement. The exec spec defines that the new process image's environment is taken from the caller's environ (or envp passed explicitly). What's passed is a copy of the value at that time.
import {execFileSync} from 'node:child_process';
process.env.APP_LOG_LEVEL = 'DEBUG';
// Child receives a copy of the parent's environment
const inherited = execFileSync('sh', ['-c', 'echo $APP_LOG_LEVEL']).toString().trim();
console.log('Value the child saw ', inherited);
// Even if the child rewrites, parent doesn't change
execFileSync('sh', ['-c', 'APP_LOG_LEVEL=WARN; export APP_LOG_LEVEL']);
console.log('Parent value unchanged', process.env.APP_LOG_LEVEL);
Value the child saw DEBUG
Parent value unchanged DEBUG
"There's a path to the child but no return path" is the parent-child relationship. To return something from child to parent, use explicit paths like standard output, files, or sockets.
Pipe stages run concurrently
Pipes covered in Shell basics connect between separate processes. The important thing here is that stages joined by | don't run in sequence — they run concurrently.
Three processes start simultaneously, and the right process reads what the left process writes. No intermediate file forms, and nothing needs to fit the whole in memory. A 10 GB log piped doesn't clog because of this.
Concurrent execution implies two behaviors:
- Slow right waits the left. When the pipe buffer fills, the writing process stops there. Overall speed is decided by the slowest stage
- Right ending early makes the left unable to write. Placing a truncating command like
headon the right leaves the left with nowhere to write, so it terminates. Infinite-output commands on the left of a pipe still stop through this path
Threads break because they share
Threads in the same process see the same memory. Handoff isn't needed so it's fast, but simultaneous writes break things.
import {Worker, isMainThread, workerData} from 'node:worker_threads';
const COUNT = 5_000_000;
if (isMainThread) {
for (const mode of ['plain add', 'Atomics add'] as const) {
const shared = new SharedArrayBuffer(4);
const workers = [0, 1].map(
() => new Worker(new URL(import.meta.url), {workerData: {shared, mode}}),
);
await Promise.all(workers.map((w) => new Promise((r) => w.on('exit', r))));
const value = Atomics.load(new Int32Array(shared), 0);
console.log(`${mode}: expected ${COUNT * 2}, matched ${value === COUNT * 2}`);
}
} else {
const {shared, mode} = workerData as {shared: SharedArrayBuffer; mode: string};
const view = new Int32Array(shared);
for (let i = 0; i < COUNT; i++) {
if (mode === 'plain add') view[0] = view[0] + 1; // Read, add, write — 3 steps
else Atomics.add(view, 0, 1); // Uninterruptible 1 step
}
}
plain add: expected 10000000, matched false
Atomics add: expected 10000000, matched true
view[0] = view[0] + 1 is one line but consists of read, add, write — 3 steps. If one thread reads then the other reads the same value before the write, adding twice increments by only 1.
This is the exact same form as lost update from Locks and isolation levels. There it was two transactions, here two threads — the only difference is who is doing "read, modify, write" being interrupted. Same reasons; same families of fixes.
| Fix | Thread example | Database counterpart |
|---|---|---|
| Make it an uninterruptible 1 step | Atomics.add | UPDATE ... SET n = n + 1 |
| Exclude others | Mutex-like exclusive control | Exclusive lock |
| Don't share at all | Split processes / pass by message | Split rows |
The false above means "didn't match", but it's not guaranteed to always mismatch. If one worker finishes before the other starts, no collision, values match. Race conditions are intermittent — that's why they're hard to catch in tests.
CPU-bound or wait-bound
"Slow" has two systems of cause with different remedies.
| Time spent | Effective remedy | |
|---|---|---|
| I/O-bound | Waiting on network, disk, database response | Event loop. One thread can hold hundreds of network waits concurrently (file reads and writes go elsewhere, and the runtime's thread count caps how many run at once) |
| CPU-bound | Using up compute units | Another thread, another process. Not waiting, so yielding doesn't reduce total |
Node.js docs state this line directly:
Workers (threads) are useful for performing CPU-intensive JavaScript operations. They do not help much with I/O-intensive work. The Node.js built-in asynchronous I/O operations are more efficient than Workers can be.
Compare running the same computation on the main thread and offloaded.
import {Worker, isMainThread} from 'node:worker_threads';
function heavy(): number {
let sum = 0;
for (let i = 0; i < 2_000_000_000; i++) sum += i;
return sum;
}
if (!isMainThread) {
heavy(); // When started as a worker, just execute and finish
} else {
// 1) Run on main thread
let ticks = 0;
let timer = setInterval(() => ticks++, 10);
heavy();
clearInterval(timer);
console.log('Timer fired while running on main:', ticks > 0);
// 2) Run same on another thread
ticks = 0;
timer = setInterval(() => ticks++, 10);
const worker = new Worker(new URL(import.meta.url));
await new Promise((resolve) => worker.on('exit', resolve));
clearInterval(timer);
console.log('Timer fired while running on another thread:', ticks > 0);
}
Timer fired while running on main: false
Timer fired while running on another thread: true
The 10 ms timer didn't fire once because one task ran the whole time. Offloading leaves the main thread free.
Another thread isn't magic. Time to compute doesn't change; what changes is whether other things can run during it. Beyond available cores, more threads just lengthen the queue.
Terminating — signals
The signal to stop a running process — two matter in practice.
| Signal | Catchable | When to use |
|---|---|---|
SIGTERM | Yes | Normal stop request. Give cleanup time |
SIGKILL | No | Force-kill unresponsive processes |
POSIX defines SIGKILL as "Kill (cannot be caught or ignored)" (IEEE Std 1003.1-2024 — signal.h). Not catchable means cleanup code doesn't run.
For deploys and container replacements, the common pattern is send SIGTERM, wait a fixed time, then escalate to SIGKILL. In that wait window, do cleanup that leaves no work behind:
- Stop accepting new work — flip Scaling directions health check to unhealthy so the load balancer removes you
- Finish in-flight work — ongoing requests, jobs already pulled from the queue
- Close connections — database, open files
Past the wait, SIGKILL fires and mid-flight work vanishes. Designing so that doesn't break is Retries and idempotency's side — "be safe to execute again" is the answer.
Common misconceptions
"More threads means faster." — Only when CPU-bound work can be distributed to free cores. If waiting is the cause, more threads don't reduce wait time. Beyond core count, switching cost makes it slower.
"Rewriting env vars in the child propagates to the parent." — It doesn't. Passed is a copy, one-way from parent to child. To return, use explicit paths like stdout or files.
"Pipe left runs to completion before right starts." — Simultaneous. Right starts reading as left writes; no intermediate file, no need to fit whole in memory.
"One process one thread means no race conditions." — Even with one thread touching memory, if multiple processes touch the same data, the same breakage happens. Files, databases, shared caches are all shared state.
"kill -9 reliably stops, so make it default." — It stops, but no cleanup runs at all. Mid-processing work, half-written files — all left as-is. Default is SIGTERM; SIGKILL is the last resort for unresponsive.
Exercises
Q1. An image conversion batch takes 3 s per item alone but 300 s when 100 items run via Promise.all. "Concurrent should be faster" — how do you explain?
Answer and explanation
Because conversion is CPU-bound.
Promise.all can parallelize only "wait time". Image conversion is compute, not wait, so it runs sequentially on the same thread. They start simultaneously but don't compute simultaneously.
To shorten, distribute computation to different execution flows.
- Send to threads — prepare workers equal to core count and distribute conversions
- Send to processes — enqueue and let worker processes handle. One failure doesn't take others down
Either way, the ceiling is core count. With 4 cores, theoretical max is 4× — not 100×. 100 items on 4 cores takes 25× the time of one.
Q2. Every deploy loses several order confirmation emails. Logs show "order saved" and nothing after. What's happening and how to fix?
Answer and explanation
Likely the old process is being stopped before reaching mail sending.
Deploys signal old processes to stop. If mid-processing reaches SIGKILL, execution vanishes there. Logs showing "saved" and no continuation matches that shape.
Triage and fix in two stages:
- Confirm cleanup runs on stop signal. Not catching
SIGTERMmeans default action — immediate exit. Stop accepting, finish in-flight, then exit - Confirm the wait is enough. Even with cleanup code, if processing runs longer than the pre-
SIGKILLgrace period, same result
Also make it recoverable when it does vanish. Not sending email inline with save; enqueue instead — the job survives process death. Combine with Retries and idempotency so re-execution doesn't duplicate.
Q3. A visit counter is updated from multiple workers. Tests increment correctly, but production sometimes doesn't match. Candidate causes and verification?
Answer and explanation
A race condition where "read, modify, write" is interrupted is the top candidate.
If the counter update is "read current, add 1, write back", two workers reading the same value lose 1 update.
Not reproducing in tests is a property of race conditions. If one finishes before the other starts, no overlap, correct value. Higher load makes overlap more likely, matching "only in production".
Verification:
- Read whether the update is one step. App-side "read then write" is a candidate. In databases, whether it's
UPDATE ... SET n = n + 1orSELECTthenUPDATE - Increase overlap to reproduce. Increasing concurrency reveals differences. If the miss amount grows with concurrency, this is it
Three families of fix: make it uninterruptible one step, exclude others with mutex, or don't share (count per worker, aggregate later). For a counter, the first is simplest and fastest — same choice as in Locks and isolation levels.
Summary
- Process is a container for resources; thread is a flow inside. Processes split memory; threads share. Call stacks are per-thread, and shared visibility may be overlaid by the runtime
- New processes are created as copies of the current one, then overlaid with a different program. Environment variables pass by copy, so child changes don't return to parent
- Pipe stages run concurrently. No intermediate files; slow right waits left; right ending ends left too
- Thread-shared memory has "read, modify, write" interruption. Same shape as lost update in Locks and isolation levels; same fix families
- Race conditions are intermittent. Sometimes matching is why tests don't catch
- Wait-bound → event loop; compute-bound → another execution flow. Ceiling is available cores
SIGTERMis catchable;SIGKILLisn't. Only the former can run cleanup, so design stopping as "stop accepting → finish in-flight → close"
- IEEE Std 1003.1-2024 — fork — Child process created as copy of parent
- IEEE Std 1003.1-2024 — exec — Process image replacement and environment passing
- IEEE Std 1003.1-2024 — signal.h — Signal list and default actions
- Node.js — worker_threads — Fitting workloads and shared-memory means
- Laravel API Development Guide — Queues in production — Number of worker processes and monitoring
What to read next
- Datetimes and time zones — On to Part 14. From how processing lines up to how values themselves are represented
- Locks and isolation levels — Seeing the same contention from the database side
- Scaling directions — The decision to add processes for handling load