Processes, Threads & Tasks

Threads: One Address Space, Several Instruction Streams

What a thread is belongs to Operating Systems. What matters here is the consequence of the design: every thread in a process can reach every object every other thread can reach, with no ceremony, no declaration and no error. Sharing is the default, and defaults are what you forget to check.

▶ Run the lab

The question this answers

The question

If every thread can reach every object, which of those objects actually needs protecting, and how do I know?

The work

A request handler running on eight threads, maintaining an in-memory rate-limit table: for each API key, a request count and a window start timestamp.

What is shared

The rate-limit map itself, and — separately and more importantly — the two-field counter object inside each entry. Also the process's file descriptors, its logger, its module-level configuration and every default-mutable object anybody captured in a closure.

The invariant — what must stay true under every interleaving

For each API key, count equals the number of requests admitted in the window that began at windowStart, and no request is admitted once count has reached the limit.

Synchronization exists to preserve this sentence. If a schedule can make it false, the code is wrong no matter which primitive it uses.

WorkCan it overlap?Can it parallelise?What is shared?What ordering?What synchronization?Where is contention?What can deadlock?What can race?What is gained?What complexity?

What is private, and what is not

A thread gets its own stack, its own registers and its own program counter. That is the complete list. The heap, the globals, the loaded code, the file descriptors, the signal handlers and the memory mappings all belong to the process, and every thread in it sees the same ones. [[threads-intro]] and [[process-memory-layout]] cover the mechanics; the design consequence is worth stating bluntly.

Local variables holding primitives are private because they live on the stack. Local variables holding *references* are private references to shared objects — which is the distinction that produces most accidental sharing. Passing an object into a thread makes the reference local and the object shared, and nothing in most languages' syntax distinguishes that from passing a copy.

So the practical question is never "is this variable shared". It is "can two threads reach this object, and does either write to it". If the answer is yes and yes, it needs an invariant and a protection. If either is no — the object is thread-confined, or it is immutable — you need nothing, and that is the cheapest correctness there is. See [[immutability]] and [[thread-safety]].

One process, three threads: what each owns and what they all share
read + writeread + writeread + writethe object that needs an invariantread + writeread + writeread only — no protection neededshared: two threads writing one fd interleavesharedProcess — one address spaceThread 1: stack, registers, PC (private)Thread 2: stack, registers, PC (private)Thread 3: stack, registers, PC (private)Code + constants (read-only: safe)Globals, module state, singletonsHeap — every object, reachable by allFile descriptors, sockets, loggerrateLimits map → { count, windowStart }
ClientGateway / LBServiceWorkerDatabaseCacheQueue / LogObject storageCDNExternal system

The invariant spans two fields, so protecting one is not enough

The rate-limit entry has two fields and one invariant that ties them together: count is meaningful only relative to windowStart. Any code that resets the window and the count must do both, and no other thread may observe the moment between them.

The schedule below is what happens when it can. Thread A sees an expired window and starts a reset; thread B reads count after A wrote the new windowStart but before A zeroed count. B sees the old count against the new window, concludes the client is over its limit, and rejects a request that should have been admitted. Then A finishes the reset, and the count is wrong for the rest of the window.

Note what would not have helped. Making count an atomic integer makes each read and each write indivisible and leaves this bug completely intact, because the invariant spans two fields and two operations. Using a concurrent map makes map operations safe and leaves this bug intact for the same reason. The unit of protection is the invariant, not the field. [[atomics-are-not-magic]] and [[finding-the-critical-section]] are the follow-ups.

Two request threads and one two-field rate-limit entry. The window reset is not atomic.ILLUSTRATIVE
Invariant · count equals the number of requests admitted since windowStart, and a request is rejected only when count has reached the limit (100) within the current window.
#Thread A — request for key k9Thread B — request for key k9State
1read entry k9 → { count: 100, windowStart: 12:00:00 }·count=100 windowStart=12:00:00 now=12:01:03
2window expired (60s elapsed) → begin reset·count=100 windowStart=12:00:00
3write windowStart = 12:01:03·count=100 windowStart=12:01:03
4·read entry k9 → { count: 100, windowStart: 12:01:03 }count=100 windowStart=12:01:03
5·window fresh, count >= 100 → reject with 429count=100 windowStart=12:01:03 rejected=1
✕ A request is rejected although zero requests have been admitted in the current window. count and windowStart describe different windows.
6write count = 1 (this request admitted)·count=1 windowStart=12:01:03 rejected=1
7·client retries; admitted normallycount=2 windowStart=12:01:03 rejected=1
A customer sees intermittent 429s at roughly the window boundary, at a rate of about one per minute per key, and it never reproduces locally. Making count atomic does not fix it. Using a concurrent map does not fix it. The critical section is "read entry, decide, update entry" as one unit, because that is the span of the invariant.

The fix, and what the fix costs

language-specific· Written in TypeScript for readability; the reasoning is language-independent. In CPython, threads cannot execute this handler on separate cores anyway — see `[[python-threads-vs-processes]]`.

The corrected version below does the obvious thing — takes a per-key lock around the whole read-decide-update sequence — and it is worth being precise about why each detail is there. The lock is per key, not global, so unrelated API keys do not serialise against each other: this is sharding the lock, and it is the difference between a rate limiter and a bottleneck. The critical section contains no I/O and no allocation of consequence, because a lock held across a slow operation converts one request's latency into every waiter's. And the entry is mutated only inside the lock, with nothing escaping.

The costs are real and should be named. Every request now takes and releases a lock, which is tens of nanoseconds uncontended and rather more when a hot key is being hammered by eight threads at once — that hot key is now a serialisation point, and [[contention-costs]] is where it goes when it becomes a problem. The per-key lock map is itself shared state needing its own thread-safe construction, and creating a lock for a key that appears once is a small memory leak unless entries are evicted.

The alternative worth considering before any of this: do not share the counter. Give each thread its own counter and reconcile periodically, accepting a bounded over-admission. That is [[copy-vs-share]] reasoning, and for rate limiting it is frequently the better answer, because the requirement is usually "approximately 100 per minute" and not "exactly 100".

Every operation is individually thread-safe. The invariant is not.
1// A concurrent map. Atomic counts. Still wrong.
2const limits = new ConcurrentMap<string, { count: AtomicInt, windowStart: number }>()
3
4function admit(key: string): boolean {
5 const e = limits.get(key) // atomic map read
6 if (Date.now() - e.windowStart > 60_000) {
7 e.windowStart = Date.now() // <-- another thread can read
8 e.count.set(0) // between these two lines
9 }
10 if (e.count.get() >= 100) return false // atomic read of a field that
11 e.count.increment() // is now inconsistent with
12 return true // windowStart
13}
14// Three atomic operations and a concurrent map protecting an invariant
15// that spans all of them. Each step is indivisible; the sequence is not.
The lock spans the invariant, is sharded by key, and holds nothing slow.
1const locks = new ConcurrentMap<string, Mutex>() // one lock per key, not one global
2const limits = new Map<string, { count: number, windowStart: number }>()
3
4function admit(key: string): boolean {
5 const lock = locks.computeIfAbsent(key, () => new Mutex())
6 return lock.withLock(() => { // critical section = the whole
7 let e = limits.get(key) // read-decide-update sequence
8 if (!e) { e = { count: 0, windowStart: Date.now() }; limits.set(key, e) }
9 if (Date.now() - e.windowStart > 60_000) {
10 e.windowStart = Date.now(); e.count = 0 // both fields, atomically w.r.t. readers
11 }
12 if (e.count >= 100) return false
13 e.count += 1
14 return true
15 }) // no I/O, no logging, no awaits inside
16}
17// Cost: a lock per request; a hot key serialises its own traffic across all
18// eight threads; the lock map needs eviction or it grows with the key space.

Individually atomic operations do not compose into an atomic sequence. The invariant ties count to windowStart, so the critical section must be the whole read-decide-update, and the lock must be per key so unrelated keys do not serialise. What it costs: uncontended lock overhead on every request, a serialisation point on hot keys, and a lock map that must be bounded.

Key points

  • A thread privately owns a stack, registers and a program counter. Everything else in the process is shared with every other thread.
  • A local variable holding a reference is a private reference to a shared object, and nothing in the syntax marks the difference.
  • The question is never "is this variable shared" but "can two threads reach this object, and does either write to it".
  • Thread-confined and immutable objects need no protection at all, and arranging for that is the cheapest correctness available.
  • The unit of protection is the invariant, not the field. A two-field invariant is not protected by making each field atomic.
  • Concurrent collections make individual operations atomic. They do not make your read-decide-update sequence atomic.
  • Shard the lock to the granularity of the invariant. One global lock around a per-key invariant turns a rate limiter into a queue.
  • Never hold a lock across I/O, logging or a suspension point — that converts one operation's latency into every waiter's.

The loop, answered

Every field is required, which is why no lesson here can recommend concurrency without naming the interleaving that breaks it, the complexity it adds, and the simpler thing to consider first.

How it works
  • The process creates a thread; the kernel allocates a stack and a schedulable entity that shares the process's page tables. See [[threads-intro]].
  • The new thread begins executing a function with its own stack frame; any reference passed to it points into the same shared heap.
  • Reads and writes to shared objects go through the same virtual addresses, and on a multi-core machine they can genuinely overlap.
  • A mutex serialises entry to a region: at most one thread inside, and the release/acquire pair also establishes visibility of the writes made inside. See [[mutex]] and [[happens-before]].
  • The scheduler may preempt a thread between any two instructions, so any read-modify-write that is not explicitly atomic is exposed.
Interleavings that matter
  • A reads the entry, decides, updates, releases; B does the same afterwards — the serialised schedule the lock guarantees, and the only one that preserves the invariant.
  • A writes windowStart, B reads both fields, A writes count — the torn-invariant schedule above, producing a spurious rejection.
  • A and B both see count = 99 and both admit, so 101 requests are admitted in the window. A pure lost-update on the counter, and the one people expect.
  • A holds the per-key lock and makes a 40 ms Redis call inside it; seven other threads with the same key block for 40 ms each. The lock is correct and the latency is now serialised. See [[lock-scope]].
  • A creates a lock for key k9 while B does the same and the map put is not atomic: two distinct lock objects for one key, so both threads "hold the lock" and the protection silently does nothing.
What it guarantees — and does not
  • The runtime guarantees each thread has a private stack. It guarantees nothing about any object either thread can reach.
  • A mutex guarantees mutual exclusion for the region it encloses, and — in a language with a memory model — that writes made inside are visible to the next thread that acquires it.
  • It does not guarantee fairness. A thread can be starved by others repeatedly winning the lock unless the implementation promises otherwise. See [[fairness]].
  • It does not guarantee anything about code outside the region, including a caller that reads the same fields without taking the lock.
  • A concurrent collection guarantees per-operation atomicity and nothing about sequences of operations. This is the single most common misreading of a thread-safety guarantee.
Where contention appears
  • A hot API key serialises all eight threads onto one lock, so its throughput is one thread's worth no matter how many cores exist. See [[hot-keys]].
  • A single global lock instead of per-key locks serialises every request in the service, which is the same bug with a much larger blast radius.
  • The shared map itself is contended on insert, and on resize far more so.
  • Adjacent counters in one array or one object can share a cache line, so unrelated keys ping-pong it between cores — contention with no lock involved. See [[false-sharing]].
How it fails
  • Lost update: two threads read the same count and both write, so one admission disappears.
  • Torn invariant: a reader observes fields updated at different times and acts on a state that never validly existed.
  • Data race: unsynchronised conflicting access with at least one write. In C++ this is undefined behaviour, not merely a wrong value. See [[data-races]].
  • Deadlock, once there is more than one lock and the acquisition order is not fixed. See [[lock-ordering]].
  • Lock convoy on a hot key: every thread queues, and the queue itself becomes the dominant latency. See [[lock-convoy]].
  • Silent non-protection: two lock objects for one logical resource, so mutual exclusion never happens and everything looks correct in review.
When it helps
  • Compute-bound work that genuinely must share a large mutable structure — an index, a cache, a simulation grid — where copying it per worker is not affordable.
  • Servers with tens to low hundreds of concurrent operations, where a stack per operation is affordable and the programming model is simpler than async.
  • Any runtime where threads occupy separate cores and the work partitions poorly enough that message passing would be awkward.
When it hurts
  • Tens of thousands of concurrent operations: a stack each is gigabytes, and tasks or an event loop are the right shape. See [[tasks-vs-threads]].
  • Waiting-bound work at scale, where threads spend their lives blocked and you pay stacks and switches for nothing.
  • Code that can crash the process — a native decoder, a foreign function — where one bad input takes down every in-flight request.
  • CPython, for CPU-bound work, where threads do not execute bytecode on separate cores. See [[python-threads-vs-processes]].
How you would know
  • Lock wait time separately from lock hold time. Wait tells you about contention; hold tells you whether the critical section is too big. See [[lock-wait-metrics]].
  • Contended-acquisition rate per lock. A lock that is never contended costs almost nothing and is not worth optimising.
  • Thread dumps during a stall: the distribution of RUNNABLE, WAITING and BLOCKED frames names the problem in one look. See [[thread-dumps]].
  • A race detector — TSan, Helgrind, the Go race detector — on the test suite. It finds data races that have not yet produced a symptom. See [[race-detectors]].
  • Per-key rejection rate against per-key admission rate, which is what would have surfaced the torn-invariant bug as "429s with count zero".
Complexity it introduces
  • Every shared object acquires a documented invariant and a documented protection, and both must survive every future edit.
  • Correctness becomes non-local: a function is correct only in the context of what other threads may do between its statements.
  • More than one lock means a lock ordering, which must be written down and enforced, because deadlock is a global property nobody sees locally.
  • Lock granularity is a permanent tuning axis — too coarse serialises, too fine deadlocks and costs memory — with no correct answer independent of the workload.
  • The debugging toolchain expands: thread dumps, lock profilers, race detectors and stress tests are all now part of the project.
Simpler alternatives
  • Do not share. Thread-confine the state, or give each thread its own copy and reconcile — for approximate counters this is both simpler and faster. See [[copy-vs-share]].
  • Make it immutable. An immutable object needs no lock, and replacing a whole entry with a compare-and-swap can be simpler than mutating two fields.
  • Move the invariant to a store that has atomic primitives: a Redis INCR with a TTL is one round trip and one atomic operation, and it also works across processes.
  • Message passing: one owner thread for the rate-limit table and a channel to it. No locks, no interleavings, at the cost of a hop. See [[actor-model]].
  • Async on one thread, when the work is waiting-bound — though note this removes data races and not race conditions. See [[overlapping-progress]].

Two increments, twenty schedules: find the one that loses an update

Two increments, twenty schedules
Both tasks run counter++ on the same variable. Drive the schedule yourself: read, add, write are three separate steps, and the scheduler may cut between any two of them.
6/6 steps
counter
2
increments completed
2
rA / rB
1 / 2
invariant
holds
Invariant · after k completed increments, counter === k. No update is lost.
#Task A — counter++Task B — counter++State
1rA ← counter·counter=0 rA=0 rB=0
2rA ← rA + 1·counter=0 rA=1 rB=0
3counter ← rA·counter=1 rA=1 rB=0
4·rB ← countercounter=1 rA=1 rB=1
5·rB ← rB + 1counter=1 rA=1 rB=2
6·counter ← rBcounter=2 rA=1 rB=2
counter = 2, and both callers are right. This schedule happens to be safe because one task finished entirely before the other started. Safe once is not safe: press "Enumerate all" to see how many of the possible schedules do not. Testing samples this space; it does not cover it.
SIMPLIFIEDcounter++ modelled as three indivisible steps. Real compilers and CPUs can split it further, or fuse it into one atomic instruction.

if (balance >= 100) withdraw(100) — drive it until it overdraws

if (balance >= 100) withdraw(100)
Two withdrawals of 100 from an account holding 100. The check and the debit are separate operations; you decide who runs when.
balance = 100

withdraw(amount):        # both tasks run this concurrently
    b = read(balance)    # 1
    if b >= amount:      # 2  <- decided on a value that may already be stale
        debit(amount)    # 3
0 schedules tried
balance
0
paid out
100
A decided
withdraw
B decided
Invariant · balance >= 0 — the account is never overdrawn.
#Withdrawal A (100)Withdrawal B (100)State
1rA ← read balance·balance=100 paidOut=0
2if rA >= 100·balance=100 paidOut=0
3debit 100·balance=0 paidOut=100
Balance is 0 and nothing has broken yet. Watch for the shape: both tasks passing step 2 before either reaches step 3. That is check-then-act, and the check is only as good as the instant it was made.
SIMPLIFIEDThe debit itself is modelled as atomic. The bug is the gap between the check and the act — not the arithmetic.

A mutex buys correctness with throughput

A mutex buys correctness with throughput
The same counter, unlocked and locked. Left column: what the schedules do. Right column: what the lock costs. Both are always on screen because you never get to choose only one.
4 cores · 4 ms CPU per task
No lock18/20 schedules lose an update
correct schedules2 · 20 possible interleavings of the two tasks
throughput
952/s
effective parallelism
3.81
Mutex around the incrementalways 2
correct schedules2 · 2 possible interleavings of the two tasks
throughput
500/s
effective parallelism
2.00
The lock removes every failing schedule — not by making them unlikely, but by making them unreachable: with the read-modify-write inside one critical section there are only 2 schedules left and neither loses an update. It costs 47.5% of throughput (952/s → 500/s) and drops effective parallelism from 3.8 to 2.00 on 4 cores. At 2 ms the region is small relative to the 4 ms of work, so most of the task still runs in parallel. This is what "small critical section" buys — and it is the only knob here that is free. What the mutex does not give you: ordering between the tasks, fairness, or protection for any other variable. It protects the region you put it around, and nothing else.
SIMULATEDSIMPLIFIEDSchedule counts are exact for this model; throughput comes from the lab model, not a measurement.

How much of the task is inside the lock?

How much of the task is inside the lock?
One 5 ms task on 8 cores. Slide the fraction of it that has to run inside the critical section and watch the parallelism the machine can actually deliver.
throughput1,000/s · 1.00 ms locked · 4.00 ms parallel
effective parallelism5 · 8 cores available · ceiling for this lock scope is 5.0
lock busy
90.0%
lock wait
9.0 ms
cores idle
37.5%
1 workerdashed = linear speedup16 workers · max 16.0×
Effective parallelism as workers are added, at the current lock scope. The dashed line is what more workers would buy if nothing were serialised.
20% of each task holds the lock, so 20% of the work is serialised no matter how many cores you own. Effective parallelism is 5.00 of 8 — the ceiling is 100/20 = 5.0× and no hardware purchase moves it. This is Amdahl's law arriving through a lock rather than through an algorithm. The move is to shrink the region, not to hold it more cleverly: compute outside the lock, take it only to publish; or split the state so tasks contend on different locks. Both cost complexity — the lock you can delete is always cheaper than the lock you optimise.
SIMULATEDA model of a single global lock. Real locks add acquisition cost, cache-line traffic and unfairness on top of this.

What people believe, and what is true

Claim

I used a concurrent map, so the code is thread-safe.

Reality

The map's operations are atomic. Your read-decide-update sequence spans three of them and is not. Thread-safe containers protect the container, never your invariant.

Claim

Local variables are private, so passing data into a thread is safe.

Reality

The reference is private; the object is shared. Passing a mutable object to a thread is sharing it, and nothing in the call site says so.

Claim

Making the counter atomic fixes it.

Reality

It fixes single-field lost updates. It does nothing for an invariant spanning count and windowStart, which is the actual bug.

Claim

It never happens in testing, so it is rare enough to ignore.

Reality

It happens at window boundaries under concurrent load — a condition tests do not create and production creates constantly. Rarity in CI is not rarity in production.

Go deeper

Overview

Threads share everything except their stacks. That makes communication free and mistakes free too, because nothing marks a shared object as shared.

Practical

For each object two threads can reach, write down the invariant and the protection. Make the critical section exactly as wide as the invariant, shard the lock to match the data, and never hold one across I/O.

Advanced

Thread safety is a property of an invariant over a set of fields, not of a type. This is why "is this class thread-safe?" is unanswerable without knowing what invariant the caller needs — and why composing two thread-safe objects almost never yields a thread-safe operation.

Apply it