Concurrencycritical sectionmutual exclusiongranularitylock hold timeAmdahl

Critical Sections

A critical section is the stretch of code that touches shared state and must not interleave with another such stretch; synchronisation exists to enforce that, and its cost is decided by how much you put inside, how long you hold it, and how many threads want it — which Amdahl’s law turns into a hard ceiling on speedup.

ConceptualCPythonLinux
Interview question
Progress

The problem

You know the read-modify-write is a race. The fix is "only one thread at a time in here". But where does *here* start and end, how much should it include, and what happens to the other 31 cores while one is inside?

Defining the section

A critical section is a sequence of operations on shared state that must appear atomic to every other thread — no other thread may observe or modify that state between the first operation and the last. The counter++ of Race Conditions is a three-instruction critical section. "Check the balance, then debit it" is one. "Remove the node from the list and update its neighbours’ pointers" is one; a reader that sees the list halfway through walks into freed memory.

The classic requirements, from Dijkstra’s 1965 formulation, are three. Mutual exclusion: at most one thread inside at a time. Progress: if no thread is inside and some want in, one of them gets in — the mechanism cannot deadlock by itself. Bounded waiting: a thread that wants in eventually gets in — the mechanism cannot starve a thread forever. Peterson’s algorithm and Dekker’s algorithm show that two threads can achieve all three with plain loads and stores *given a sequentially consistent memory* — which real hardware does not provide, which is why real implementations use atomic instructions (Atomic Operations) and are packaged as a Mutexes.

The boundaries are the design decision. Too narrow and the invariant is not protected: locking each of two updates separately leaves a window between them where another thread sees the first without the second. Too wide and you serialise work that did not need serialising. The right boundary is the smallest region across which the shared state’s invariant is temporarily false.

The invariant is "sum of both accounts is constant"; the section must cover both writes
1transfer(from, to, amount):
2 lock(accounts) # -- critical section begins
3 from.balance -= amount # invariant false here...
4 to.balance += amount # ...and restored here
5 unlock(accounts) # -- ends
6 audit_log.write(...) # I/O: keep it outside

Granularity: coarse vs fine

One lock for everything is coarse-grained: simple to reason about, impossible to deadlock against itself, and a serialisation point for the whole program. The Linux kernel’s Big Kernel Lock was exactly this — one lock around most kernel code — and its removal, completed in 2011, took over a decade of splitting it into per-subsystem and per-object locks. CPython’s GIL is a coarse-grained lock around the interpreter: it makes the runtime’s own data structures safe cheaply and caps Python-level parallelism at one core, which is why CPU-bound Python scales with processes, not threads (How C++, JavaScript and Python Map onto the OS).

Fine-grained locking gives each object, bucket or row its own lock so unrelated operations proceed in parallel. Java’s ConcurrentHashMap locks per bucket (per bin); a database locks per row (Locks and Deadlocks); a kernel locks per inode, per socket, per page. The cost is complexity: more locks means more possible orderings and more ways to deadlock (Deadlocks), more memory for lock words, and more cache-line traffic when locks are acquired and released. Lock *striping* — N locks, choose by hash — is the usual middle ground.

Reader-writer locks split by intent instead of by object: many readers or one writer. They pay off when reads dominate and the section is long enough that the extra bookkeeping (a reader count, two wait queues) is amortised; for short sections a plain mutex is often faster. RCU (read-copy-update, pervasive in the Linux kernel) goes further: readers take no lock at all, writers publish a new version and free the old one after every reader that could have seen it has finished. It is the mechanism behind lock-free routing-table and dentry lookups.

Granularity trade-offs
StrategyParallelismComplexityDeadlock riskExample
One global locknone inside the locked regionlowestnone (one lock)Big Kernel Lock, CPython GIL
Per-subsystem / stripedmoderatemoderatelow if orderedN locks chosen by hash
Per-objecthighhighhigh — needs a lock orderper-row DB locks, per-inode kernel locks
Reader-writerhigh for readsmoderatewriter starvationconfig caches, routing tables
RCU / lock-freereaders never blockhighestnone for readerskernel dentry cache, concurrent queues

Hold time, and what to keep out

Linux

Contention is the product of how often a lock is taken and how long it is held. Halving the hold time halves the contention at the same request rate, so the second design lever after granularity is what you do while holding the lock. The rule is: only the operations on the shared state. Everything else — computing the new value, formatting a log line, allocating the node you are about to insert — happens before or after.

Three things must never be inside a critical section. I/O: a write to a log or socket can block for milliseconds, and every other thread wanting the lock blocks with it; a network call under a lock turns a 100 ns section into a 100 ms one. Memory allocation: malloc and new take their own internal locks and can page-fault; holding a lock while allocating both lengthens the section and creates a lock-order dependency you did not choose. Callbacks into unknown code: a user callback may try to take the same lock (deadlock with a non-recursive mutex) or a different one in the wrong order. Copy what you need out, release, then call.

The measurable symptom of a fat section is convoying: threads arrive faster than the section drains, queue up on the lock, and each one’s wait includes every predecessor’s hold time — including the time predecessors spent descheduled while holding the lock. Latency then depends on the scheduler rather than the work. perf lock on Linux, -XX:+PrintConcurrentLocks on the JVM and a py-spy dump showing many threads in acquire are the tools.

  • Inside: reads and writes of the shared state, and nothing that can block, allocate or call out.
  • Prepare before, publish inside, act after.
  • Hold time under contention is the section time *plus* any time the holder is descheduled — a preempted holder stalls everyone.

Amdahl: the ceiling on the whole program

Conceptual

Every critical section is a serial segment in an otherwise parallel program, and Amdahl’s law says the maximum speedup on N cores is 1 / (s + (1 − s) / N), where s is the serial fraction. If 10% of the work happens under one lock, infinite cores give at most 10×; on 32 cores you get about 7.8×; on 64 about 8.8×. If 1% is serial the ceiling is 100×. The lock does not need to be slow to cap you; it needs only to be *there*.

This is why the granularity and hold-time work above matters beyond fixing races: it is the difference between a service that scales to a machine’s cores and one that flattens at four. It is also why measurement precedes lock-splitting — a lock that is held 0.1% of the time is not your problem even if it looks ugly, and a lock held 15% of the time is your problem even if each hold is 200 ns. The serial fraction is what you measure; the number of locks is an implementation detail.

Amdahl’s law for a 10% serial fraction
cores   1     2     4     8    16    32    64   inf
speedup 1.0   1.8   3.1   4.7   6.4   7.8   8.8  10.0

Key points

  • A critical section is the region where a shared invariant is temporarily false; mutual exclusion, progress and bounded waiting are the requirements on whatever guards it.
  • Boundaries: cover every operation of one logical update, and nothing else.
  • Coarse locks are simple and serialising (BKL, the GIL); fine locks parallelise and multiply the ways to deadlock; striping, reader-writer locks and RCU are the intermediate points.
  • Keep I/O, allocation and callbacks out of the section; hold time under contention includes time the holder is descheduled.
  • Amdahl: a 10% serial fraction caps speedup at 10× no matter how many cores. Measure the serial fraction before splitting locks.

Why does this exist?

Mechanisms are answers to constraints. Open each question before reading the answer.

Why not just make every shared operation atomic and skip the concept?

Because most invariants span several memory locations — two balances, a node and its neighbours — and hardware atomics cover one word. The critical section is how you make a multi-location update appear atomic.

Why did the Linux kernel spend a decade removing one lock?

The BKL made a 64-core machine behave like a 1-core machine inside the kernel. Replacing it with per-object locks was worth years of work because the serial fraction it imposed capped every workload on the machine.

Why does a preempted lock holder hurt so much more than a slow one?

A slow holder delays waiters by its section time; a preempted holder delays them by a full scheduler timeslice, milliseconds, while nothing useful happens. This is the convoy effect, and it is why spinlocks in user space are dangerous and why kernel spinlocks disable preemption.

How it fails

What the failure looks like from inside real software.

  • A service scales from 1 to 4 cores and flattens; profiling shows 20% of time under one lock — Amdahl’s ceiling, not a hardware limit.
  • p99 latency spikes to tens of milliseconds under load: a log write inside a critical section stalls every thread queued on the lock.
  • Deadlock introduced by a callback invoked while holding a lock; the callback re-enters and tries to take the same non-recursive mutex.
  • Two updates each locked separately; a reader between them observes a debit with no matching credit — the section was too narrow for the invariant.
  • A reader-writer lock under a constant read stream never admits the writer; a config change takes minutes to apply.
  • Fine-grained locks added without an order; a rare deadlock appears weeks later between two object locks taken in opposite orders.