So far every program in this course has done one thing at a time. Real software rarely gets to: a browser downloads, decodes and draws at once; a server talks to thousands of people; your phone has six or eight cores and wants to use them.
Doing many things at once is where some of the strangest bugs in computing live: programs that work a million times and fail on the next. This chapter shows exactly why, in 3D and in code you can run, and how real systems tame it: locks, atomics, messages, and whole languages designed around the problem.
Every code block runs in your browser. Edit it, break it, press Run.
01 · PROCESSES & THREADS
Processes and threads
When you start a program, the operating system creates a process: a running program with its own private memory (its address space), its open files, and at least one thread. A thread is one line of execution: a program counter saying which instruction is next, a set of registers, and a stack for its function calls.
A process can have many threads. They all share the process's memory, which is why they are fast to start and fast to talk to: they just read and write the same variables. Two processes share nothing: the OS keeps them apart, and they can only talk through channels it provides (pipes, sockets, files). On the stage, process A's three threads all touch one memory floor; process B can't reach it.
share-or-copy.js
12345678910111213
// A thread shares its process's memory.
// A new process gets its own copy of it.
constmemory={count:0,name:"game state"};
constthreadView=memory;// same object
constprocessView=structuredClone(memory);// a full copy
threadView.count+=1;
processView.count+=100;
console.log("original:",memory.count);// the thread's change shows
JavaScript has no threads in this sandbox, so an object stands in for memory: sharing it is what a thread does; structuredClone copying it is what a new process gets.
The same thing with real threads and a real process, in Python:
threads_vs_processes.py · Python 3 on Linux or macOS
importos,threadingcounter=[0]defwork():counter[0]+=1t=threading.Thread(target=work)t.start();t.join()print("after a thread:",counter[0])# 1: the thread shares our memorypid=os.fork()# a new process (Linux / macOS)ifpid==0:counter[0]+=100# the child changes ITS copyos._exit(0)os.waitpid(pid,0)print("after a process:",counter[0])# still 1
It prints after a thread: 1 and then after a process: 1: the child process added 100 to its own copy, and the parent never saw it. (Linux makes that copy lazily, page by page, only when one side writes: copy-on-write.)
02 · CONCURRENT VS PARALLEL
Concurrent is not the same as parallel
Concurrency means several tasks are in progress over the same stretch of time. Parallelism means several things are happening at the same instant. A single core can be concurrent by taking turns: the OS switches threads many times a second (a context switch), fast enough to feel simultaneous. Only several cores can be parallel.
On the stage, three tasks of four steps each. One core interleaves them, A B C A B C…, and finishes at t = 12. Three cores run them side by side and finish at t = 4. Same work, same concurrency, very different parallelism.
round-robin.js
12345678910111213141516171819
// Three tasks, one worker: a scheduler that takes turns.
// Each task is a generator; "yield" means "I can pause here".
function*task(name,steps){
for(leti=1;i<=steps;i++){
console.log(name+i);
yield;
}
}
constready=[task("A",3),task("B",3),task("C",3)];
lettick=0;
while(ready.length>0){
constt=ready.shift();// take the task at the front
const{done}=t.next();// run it until it yields
tick++;
if(!done)ready.push(t);// not finished: back of the queue
}
console.log("finished after",tick,"turns on one core");
Press Step through and watch the ready queue rotate. This is a tiny scheduler: an OS does the same with threads, and JavaScript's event loop with callbacks.
Rob Pike, one of Go's designers, put it this way: concurrency is about dealing with lots of things at once; parallelism is about doing lots of things at once. Concurrency is a way to structure a program; parallelism is a way to run it faster. JavaScript in one page is concurrent (the event loop from the previous chapter) but not parallel, until you add workers.
…logical cores on the device you are reading this on, as your browser reports them: that many threads can truly run at the same instant. MDN: Navigator.hardwareConcurrency
03 · RACE CONDITIONS
Race conditions: the lost update
Here is the classic bug. Two threads each do count++ on a shared counter. That one line is really three machine steps:
x86-64: what count++ becomes
; count++ compiles to three separate stepsmovrax,[count]; READaddrax,1; ADD (in a register)mov[count],rax; WRITE; the atomic version is one locked instructionlockaddqword[count],1
Watch the stage. Thread 1 reads 0 into its register. Before it writes, thread 2 also reads 0. Both add 1, both write 1. Two increments ran, and the counter says 1: an update was lost. When the result depends on the exact timing of threads, that is a race condition.
The sandbox has one thread, so this program makes the gap on purpose with await; real threads get gaps by themselves, between any two instructions:
race.js
123456789101112
letcount=0;
constpause=()=>newPromise((r)=>setTimeout(r,0));
asyncfunctionincrement(name){
constseen=count;// 1. READ the shared value
awaitpause();// ...the other thread runs here...
count=seen+1;// 2. WRITE back what this thread computed
Change the seeds: every run gives a different wrong answer. That is what makes races so hard: the bug depends on timing, so it can hide in testing and appear in production.
The same bug in C with real POSIX threads. Compiled without optimisation, two runs here printed 1,762,606 and 1,969,964, never the 2,000,000 you would expect:
race.c · gcc -pthread race.c
#include<pthread.h>#include<stdio.h>longcount=0;void*work(void*arg){for(inti=0;i<1000000;i++)count++;/* load, add, store: not atomic */returnNULL;}intmain(void){pthread_ta,b;pthread_create(&a,NULL,work,NULL);pthread_create(&b,NULL,work,NULL);pthread_join(a,NULL);pthread_join(b,NULL);printf("%ld\n",count);/* rarely 2000000 */}
04 · LOCKS
Locks and mutexes
The fix is to make the read-add-write a critical section: a stretch of code only one thread may be in at a time. A mutex (mutual exclusion lock) guards it. A thread must lock it to enter; any other thread that tries has to wait until it is unlocked.
On the stage, the padlock takes the colour of whichever thread holds it, and the other thread circles, waiting. Four increments, count = 4, every time. The price: while one thread holds the lock, the other does nothing.
if(next)next();// hand it straight to the next in line
elsethis.locked=false;
}
}
letcount=0;
constm=newMutex();
constpause=()=>newPromise((r)=>setTimeout(r,0));
asyncfunctionincrement(){
awaitm.lock();
try{
constseen=count;// the critical section:
awaitpause();// nobody else can get in here now
count=seen+1;
}finally{
m.unlock();// finally: unlock even if something throws
}
}
constjobs=[];
for(leti=0;i<100;i++)jobs.push(increment());
awaitPromise.all(jobs);
console.log("100 increments, count =",count);
Delete the await m.lock() line and run it again: the count collapses.
Real mutexes are built on atomic instructions (next acts but one) plus help from the OS: a waiting thread is put to sleep rather than burning a core. Every language has them: pthread_mutex_t in C, synchronized in Java, threading.Lock in Python, sync.Mutex in Go, Mutex<T> in Rust.
05 · DEADLOCK
Deadlock: the dining philosophers
Edsger Dijkstra's puzzle: five philosophers sit round a table with a fork between each pair. To eat, a philosopher needs both neighbouring forks. Suppose each one picks up the fork on the left, then reaches for the one on the right. Every right fork is in someone's left hand. Nobody can eat, and nobody will ever put a fork down. That is a deadlock: a cycle of threads, each holding a lock the next one needs.
if(done.length===0)console.log("Deadlock: each holds what the other needs.");
},500);
Two transfers lock two accounts in opposite orders. Make T2 lock accountA first too, and both finish.
Coffman and colleagues showed in 1971 that deadlock needs four conditions at once: mutual exclusion (a fork is held by one person), hold and wait (you keep one fork while waiting for another), no preemption (nobody snatches a fork), and a circular wait. Break any one and deadlock can't happen. The usual fix breaks the circle: number the locks and always take the lower number first. On the stage, the second round does exactly that: philosopher 4 reaches for fork 0 first, the circle can't close, and all five eat in turn.
console.log("everyone takes the left fork first, ate:",attempt(leftFirst));
console.log("everyone takes the lower-numbered fork first, ate:",attempt(lowerFirst));
A cousin of deadlock is livelock: everyone politely puts their fork down and tries again, in step, forever. Another is starvation: the system makes progress, but one unlucky thread never gets the lock.
06 · ATOMICS
Atomics: one indivisible step
For a single counter, a lock is a sledgehammer. CPUs have atomic instructions that read, modify and write memory as one step no other core can split, like x86's lock add above. On the stage each thread now fires a single fetch-and-add at the cell, and the hardware lines them up.
The most powerful is compare-and-swap (CAS): "if this memory still holds what I expect, replace it; otherwise tell me what is there". Watch the stage's thread 1: it reads 2 and computes 3, but thread 2 sneaks in a fetch-and-add first. Thread 1's CAS sees 3, not 2, so it fails (the red flash), rereads, and succeeds. Nothing is ever lost, and nobody ever waits on a lock.
atomics.js
12345678910111213141516
// Atomics: read-modify-write as ONE indivisible step.
constcell=newInt32Array(1);// one 32-bit integer
Atomics.add(cell,0,1);// count += 1, atomically
Atomics.add(cell,0,1);
console.log("after two Atomics.add:",Atomics.load(cell,0));
// compare-and-swap: "if it is still 2, make it 10"
JavaScript's Atomics works on integer typed arrays. Across real threads they must sit in a SharedArrayBuffer, which browsers only allow on cross-origin isolated pages, after the Spectre attacks of 2018. This page isn't, so the last line says undefined.
The retry loop is how you build any atomic update you like:
cas-loop.js
12345678910111213141516
// Build any atomic update from compare-and-swap:
// read, compute, and only write if nobody changed it meanwhile.
// someone else got in first: go round again with the new value
}
}
constcell=newInt32Array([21]);
consttries=atomicDouble(cell,0);
console.log(cell[0],"after",tries,"try");
07 · MESSAGE PASSING
Message passing and actors
Every bug so far came from shared mutable memory. So don't share it. In the actor model (Carl Hewitt, 1973) each actor owns its state privately and has a mailbox. The only way to affect an actor is to send it a message, and it handles its messages one at a time, in order.
On the stage, actors A and B fire twelve "inc" messages at a counter actor. They queue in its mailbox, and it takes them one by one. No lock, and no lost update: nothing else can touch count.
The receive function even pauses in the middle of a message, and it is still safe, because the actor won't start the next message until this one is done.
Go's motto says the same: "Do not communicate by sharing memory; instead, share memory by communicating." Its channels come from Tony Hoare's Communicating Sequential Processes (1978). The price of messages is copying, and that you now have to think about protocols: who sends what, and what happens if a reply never comes.
08 · WEB WORKERS
Web Workers: real threads in the browser
A web page's JavaScript runs on one main thread, which also handles clicks and draws the page. A long calculation there freezes everything. A Web Worker is a real OS thread with its own JavaScript engine instance, its own memory and its own event loop. It can't see the DOM; it talks to the page only by postMessage. That is the actor model, built into every browser.
main.js
// main.js: runs on the page's threadconstworker=newWorker("prime-worker.js");worker.onmessage=(e)=>{console.log("worker found",e.data,"primes");};worker.postMessage({from:2,to:5_000_000});// a copy is sent// ...the page stays responsive while the worker counts
prime-worker.js
// prime-worker.js: runs on its own thread, with no DOMself.onmessage=(e)=>{const{from,to}=e.data;letfound=0;for(letn=from;n<to;n++)if(isPrime(n))found++;self.postMessage(found);// send the answer back};functionisPrime(n){if(n<2)returnfalse;for(letd=2;d*d<=n;d++)if(n%d===0)returnfalse;returntrue;}
This lab starts real workers on your device. It splits the job into 48 chunks; each worker asks for the next chunk when it finishes one (a work queue, which keeps all workers busy even though later chunks, with bigger numbers, are slower). Then try the main thread and watch the spinner stop.
Count the primes below 3,000,000 (there are 216,816). Your device reports ? logical cores. Watch the spinner: it is drawn by the page's main thread.
The runnable blocks on this site run inside a Web Worker too, so an infinite loop can't freeze the page; that worker is locked down and isn't allowed to start more. Messages are copied with the structured clone algorithm, or transferred: ownership of the memory moves, and the sender's copy goes empty.
post-message.js
123456789101112131415161718192021
// postMessage COPIES what you send (a "structured clone"),
// or MOVES it if you list it as a transfer.
constch=newMessageChannel();// two connected ports
constarrived=newPromise((resolve)=>{
ch.port2.onmessage=(e)=>resolve(e.data);
});
constsent={score:1,tags:["a"]};
ch.port1.postMessage(sent);
sent.score=999;// too late: a copy is on its way
sent.tags.push("changed");
constgot=awaitarrived;
console.log("receiver got:",got.score,got.tags);
ch.port1.close();
// a transfer hands the memory over instead of copying it
constbuf=newArrayBuffer(1_000_000);
constmoved=structuredClone(buf,{transfer:[buf]});
console.log("after the transfer I have",buf.byteLength,"bytes;",
"the receiver has",moved.byteLength);
09 · AMDAHL'S LAW
Amdahl's law: the limit on speedup
Why didn't the workers give a perfect speedup? Part of almost every job is serial: splitting the work, starting the workers, adding up the answers. In 1967 Gene Amdahl pointed out what that means. If a fraction p of the run time can be split across n cores and the rest can't, the best possible speedup is:
Amdahl's law
speedup(n)=1/((1-p)+p/n)
Move the sliders. The stage shows run time for 1 to 1,024 cores: the cyan parallel part shrinks with every doubling, the pink serial part never does, and it ends up being nearly all that is left. A program that is 95% parallel can never run more than 20 times faster, on any number of cores.
speedup = 1 / ((1 − p) + p / n)
1 / (0.10 + 0.90 / 16) = ×6.40
Run time on 16 cores, as a share of one core's: serial 10% + parallel 6%. Each core is busy 40% of the time. With unlimited cores: ×10.0.
amdahl.js
12345678910
// Amdahl's law: p = the share of the work that can run in parallel,
// n = the number of cores. The rest (1 - p) runs on one core.
In 1988 John Gustafson pointed out the hopeful flip side: people with more cores usually solve bigger problems in the same time, and then the parallel part grows while the serial part stays put. Weather models, AI training and rendering all live on this scaled speedup.
gustafson.js
12345678
// Gustafson's view: with more cores we usually grow the problem.
// s = serial share of the time on the parallel machine.
A CPU has a few big cores, each running its own program, with lots of silicon spent on making one thread fast. A GPU bets the other way: thousands of small lanes that all run the same small program, a kernel, each on a different piece of data. That is data parallelism, and it is perfect for pixels, physics and the matrix maths behind neural networks.
kernel.js
12345678910111213
// A GPU runs the SAME small function (a "kernel") for every index.
// Here a loop stands in for thousands of cores doing it at once.
functionadd(i,a,b,out){
out[i]=a[i]+b[i];
}
constn=8;
consta=Float32Array.from({length:n},(_,i)=>i);
constb=Float32Array.from({length:n},(_,i)=>10*i);
constout=newFloat32Array(n);
for(leti=0;i<n;i++)add(i,a,b,out);// a GPU: all at once
console.log(Array.from(out));
Each call of add is independent of the others: no shared writes, no locks. That independence is what lets a GPU run them all at once.
On NVIDIA GPUs, lanes are grouped in warps of 32 that execute in lockstep, one instruction for all 32. On the stage, rows of the grid light up together. If lanes of one warp disagree at an if, the warp runs both branches one after the other with half its lanes idle (the gold half-rows): divergence.
divergence.js
123456789101112
// GPU threads run in lockstep groups ("warps" of 32 on NVIDIA).
// If threads in a warp take different branches, the warp runs
// BOTH branches one after the other, with half its lanes switched off.
In the browser, WebGPU lets you write kernels yourself. This one adds two arrays, one invocation per element:
add.wgsl · WebGPU compute shader
// WGSL (WebGPU): one invocation per element, 64 per workgroup@group(0)@binding(0)var<storage,read>a:array<f32>;@group(0)@binding(1)var<storage,read>b:array<f32>;@group(0)@binding(2)var<storage,read_write>out:array<f32>;@compute@workgroup_size(64)fnmain(@builtin(global_invocation_id)id:vec3<u32>){leti=id.x;if(i<arrayLength(&out)){out[i]=a[i]+b[i];}}
To see a GPU's warps, cores and memory in detail, open the GPU. For what all that parallel matrix maths is for, see how a language model works.
11 · GO · RUST · ERLANG
How Go, Rust and Erlang do it
Three modern languages, three philosophies. Each one is the same counter or worker pool from this chapter.
Go: cheap threads and channels. A goroutine starts with a 2 KB stack and Go's runtime multiplexes many thousands of them onto a few OS threads. They talk through channels, straight from Hoare's CSP. A send blocks until someone receives, so the channel is the synchronisation.
workers.go · prints sum of squares: 385
packagemainimport("fmt""sync")funcmain(){jobs:=make(chanint)results:=make(chanint)varwgsync.WaitGroupforw:=0;w<4;w++{// four goroutines: cheap, started with "go"wg.Add(1)gofunc(){deferwg.Done()forn:=rangejobs{// receive until the channel closesresults<-n*n}}()}gofunc(){forn:=1;n<=10;n++{jobs<-n// send: blocks until a worker takes it}close(jobs)}()gofunc(){wg.Wait();close(results)}()sum:=0forr:=rangeresults{sum+=r}fmt.Println("sum of squares:",sum)// 385}
Rust: the compiler checks it. Rust's ownership rules, plus two marker traits (Send: safe to move to another thread; Sync: safe to share), make a data race a compile error. Shared data must be wrapped, typically Arc<Mutex<T>>, and the mutex owns the data, so you can't touch it without the lock.
counter.rs · prints count = 8000
usestd::sync::{Arc,Mutex};usestd::thread;fnmain(){// Arc: shared ownership across threads. Mutex: one at a time.letcount=Arc::new(Mutex::new(0));letmuthandles=vec![];for_in0..8{letcount=Arc::clone(&count);handles.push(thread::spawn(move||{for_in0..1000{*count.lock().unwrap()+=1;// the lock guards the data itself}}));}forhinhandles{h.join().unwrap();}println!("count = {}",*count.lock().unwrap());// always 8000}
race.rs · rejected by rustc
usestd::thread;fnmain(){letmutcount=0;leth=thread::spawn(||{count+=1;// try to share a plain variable with a thread});h.join().unwrap();}// error[E0373]: closure may outlive the current function, but it// borrows `count`, which is owned by the current function// The race never runs: the compiler refuses to build it.
Erlang: millions of isolated processes. Erlang (Ericsson, 1986, built for telephone switches) has no shared memory at all. Its processes are not OS processes but tiny ones in its own virtual machine: a new one takes 327 words of memory, about 2.6 KB. They only send messages, and when one crashes, a supervisor process restarts it. "Let it crash" is the design.
counter.erl
-module(counter).-export([start/0,loop/1]).%% A process: private state, a mailbox, one message at a time.start()->spawn(counter,loop,[0]).loop(Count)->receiveinc->loop(Count+1);{get,From}->From!{count,Count},loop(Count)end.%% In the shell:%% 1> C = counter:start().%% 2> [C ! inc || _ <- lists:seq(1, 1000)].%% 3> C ! {get, self()}, receive {count, N} -> N end.%% 1000
PRACTICE
Now you do it
Type the code yourself rather than pasting it: producing an answer builds memory that reading does not. Use the hints one at a time.
1
Close the gap
Two threads each add 1 a thousand times, and the scheduler may switch between them at any yield. Right now updates get lost. Change incrementer so every run ends at 2000. Keep the scheduler, and keep at least one yield in the loop.
This time the gap must stay: the yield between READ and WRITE stands for slow work. Use state.locked to build a lock so only one thread at a time is between the two, and every run ends at 2000.
attempt(order) simulates the dining philosophers: order(p) says which fork philosopher p picks up first and second, and it returns who managed to eat. Write myOrder so that all five eat.
There is Atomics.add, but no Atomics.max. Write atomicMax(arr, i, value): it stores value only if it is bigger than what is there, using a compare-and-swap loop so it would stay correct with other threads writing at the same time.
exercise-4.js
1234567891011
functionatomicMax(arr,i,value){
// 1. read the current value with Atomics.load
// 2. if value isn't bigger, stop
// 3. try Atomics.compareExchange; if someone changed it meanwhile, go again
}
constcell=newInt32Array([5]);
atomicMax(cell,0,3);
atomicMax(cell,0,9);
atomicMax(cell,0,7);
console.log(cell[0]);// should be 9
5
How many cores is enough?
Write speedup(p, n) using Amdahl's law, then find minCores: the fewest cores that make a program that is 95% parallel run at least 12 times faster.
exercise-5.js
12345678
functionspeedup(p,n){
// Amdahl: 1 / ((1 - p) + p / n)
}
letminCores=1;
// grow minCores until speedup(0.95, minCores) >= 12
console.log(minCores,speedup(0.95,minCores));
6
A bank account actor
Messages to an account arrive in a mailbox and are handled one at a time. Finish receive: deposit adds, withdraw subtracts only if the balance is enough (otherwise count it in rejected). Because only the actor touches the balance, there is no lock anywhere.
exercise-6.js
123456789101112131415
constmailbox=[
{type:"deposit",amount:100},
{type:"withdraw",amount:30},
{type:"withdraw",amount:500},// too much: reject it
Answer each one before you open it. Then come back tomorrow, and again next week, and answer them cold: retrieval spaced over days is the most reliable study method we know of. Why that works.
What do two threads in one process share, and what does each have of its own?
Concurrency vs parallelism, in one sentence each.
Why can count++ lose updates with two threads?
Name the four conditions for deadlock. Which one does lock ordering break?
What does compare-and-swap do?
A program is 90% parallel. What is the most speedup any number of cores can give?
Why can't a Web Worker change the page's DOM?
GO FURTHER
Resources
BOOK · FREEOperating Systems: Three Easy Pieces, Concurrency ↗Remzi and Andrea Arpaci-DusseauThe free OS textbook. Its concurrency part (threads, locks, condition variables, deadlock) is the clearest long-form version of this chapter.
PRACTICE · FREEThe Deadlock Empire ↗A game: you are the scheduler, and you win by interleaving two threads until they break. The best way to build race-condition instincts.
BOOK · FREEThe Little Book of Semaphores ↗Allen B. DowneyDozens of synchronisation puzzles with solutions, from rendezvous to dining philosophers.
DOCS · FREEUsing Web Workers ↗MDN Web DocsReal threads in the browser: workers, postMessage, transferables, and what a worker can't touch.
COURSE · FREEA Tour of Go: Concurrency ↗The Go teamGoroutines, channels, select and mutexes, each with a runnable example in the page.