The part of OS that is genuinely hard, and the part that shows up in
real code. Everything here follows from one fact: your line of code is
several machine instructions, and the scheduler can interrupt you between any two of
them.
08
Race conditions & critical sections
atomicitycritical sectioninterleaving
Why it exists
Because count++ is not one operation. It's a load, an add, and a
store — and a context switch between the load and the store means two threads can
both read 5, both compute 6, and both write 6. One increment vanishes. No compiler
warning, no crash, just a number that's quietly wrong under load.
The canonical lost update
count = 5; // two threads each run count++Thread AThread B count
mov eax, [count] → 5 5
mov eax, [count] → 5 5
add eax, 1 → 6 5
mov [count], eax 6
add eax, 1 → 6 // A's stale copy! 6
mov [count], eax 6Two increments, one result. The interleaving above is one of several; the
bug appears only for some of them, which is why races are intermittent and
why "it works on my machine" is meaningless. Add more cores and the window
gets wider, not narrower.
The properties that make races nasty
Non-deterministic. The same input gives different results.
Tests pass 999 times and fail in production.
Heisenbugs. Adding a print statement changes the timing and
the bug disappears.
Load-dependent. They surface exactly when you least want
them — under peak traffic, when interleavings are most varied.
Silent. A lost increment corrupts data without any error.
Unlike a deadlock, nothing hangs to tell you.
What a correct solution must guarantee
Any critical-section mechanism has to satisfy all three. Interviewers ask for
these by name:
Mutual exclusion. At most one thread inside the critical
section at a time.
Progress. If nobody is inside and someone wants in, one of
the waiting threads must get in — no mutual blocking, no deadlock.
Bounded waiting. A thread can be overtaken only a bounded
number of times before it gets its turn — no starvation.
A fourth, implicit requirement: no assumptions about relative speed.
A solution that only works if threads run at similar rates is not a solution.
Why you can't just do it in software
Peterson's algorithm solves it for two threads using only loads and stores, and is
worth knowing as proof that it's possible — but it doesn't generalise
cleanly to N threads and it breaks under modern memory reordering unless you add
barriers. Real implementations use a hardware atomic instruction:
test-and-set atomically read a word and set it to 1, return the old value
compare-and-swap (CAS)
atomically: if *addr == expected, store new; return success
fetch-and-add atomically add and return the previous value
The point is ATOMICITY AT THE HARDWARE LEVEL: the CPU guarantees no other
core can observe or modify the location mid-instruction, by locking the
cache line. Every lock, semaphore, and lock-free structure in existence is
built on one of these three. CAS is the most general — you can build the
others from it.
Say this
"A race condition is when the result depends on the interleaving of threads.
The classic case is count++, which is a load, an add and a store —
if two threads interleave between the load and the store, one increment is lost
silently. Any fix has to give three things: mutual exclusion, progress, and
bounded waiting. And you can't build it from ordinary loads and stores in
practice — you need a hardware atomic like compare-and-swap, because only the CPU
can guarantee no other core observes the location mid-update."
09
Synchronisation primitives
mutexsemaphoremonitorcondition variablespinlock
Why there are several
They answer different questions. A mutex answers "who may touch this data". A
semaphore answers "how many of this resource are left". A condition variable
answers "wait until the world changes". Using the wrong one is the most common
source of concurrency bugs that look like the right code.
Primitive
Answers
Key property
Use for
Mutex
"May I touch this?"
Ownership. Only the locking thread may unlock. Binary.
No ownership, so no recursion checks and no priority inheritance
Signalling between threads. Prefer a mutex for mutual exclusion.
Condition variable
"Wait until X is true"
Always paired with a mutex. Atomically releases the lock and sleeps.
Waiting on state: queue non-empty, job done
Monitor
—
A language construct bundling a mutex + condition variables with the data
Java synchronized, C# lock
Spinlock
"May I touch this?"
Busy-waits instead of sleeping. No context switch.
Very short critical sections; inside the kernel where you can't sleep
RWLock
"May I read / write?"
Many readers or one writer
Read-heavy shared state — but watch writer starvation
Barrier
"Has everyone arrived?"
All N threads block until all N reach it
Phased parallel computation
Mutex vs semaphore — the answer they want
The distinction is ownership, not the count. A mutex is owned by
the thread that locked it, so only that thread can unlock it — which lets the
runtime detect misuse, support recursive locking, and do priority
inheritance (temporarily boost a low-priority lock holder so it can finish).
A semaphore has no owner: any thread may signal(), which is precisely
why it works for signalling between threads and why it cannot offer priority
inheritance. So: mutex for mutual exclusion, semaphore for counting and
signalling.
Semaphore semantics
wait(S) / P(S) / acquire():
S--; if (S < 0) block this thread on S's queue
signal(S) / V(S) / release():
S++; if (S <= 0) wake one thread from S's queue
Both operations are atomic — that's the entire contract.
A negative S means |S| threads are waiting.
Initial value tells you what it IS:
1 → mutual exclusion (lock)
0 → signalling / rendezvous ("wait for me to finish")
N → N interchangeable resources (pool of N connections)
Condition variables, and the rule nobody remembers
mutex_lock(&m);
while (queue_is_empty()) // WHILE, never IF
cond_wait(&cv, &m); // atomically unlocks m and sleeps;
// re-locks m before returning
item = dequeue();
mutex_unlock(&m);
Why while and not if
Two independent reasons, and naming both is the strong answer:
Spurious wakeups. POSIX explicitly permits
cond_wait to return without any corresponding signal. The standard
allows it because it makes implementations faster on some platforms.
Stolen wakeups. More importantly, even with a genuine signal,
another thread can be scheduled first and consume the item before you re-acquire
the lock. By the time you run, the condition is false again.
With if, you proceed to dequeue() on an empty queue.
With while, you re-check and go back to sleep. This is a real bug that
ships constantly.
Spin or sleep?
Spinning burns CPU but costs no context switch (~1–3 µs direct, tens of µs with
cache effects).
Sleeping frees the CPU but pays two switches.
Spin if the expected wait < the switch cost, you're on a multiprocessor
(on one core, spinning waits for a thread that cannot run — pure waste),
and you can't sleep (interrupt context). Sleep if the wait might be long or involves I/O.
In practice: adaptive mutexes spin briefly, then sleep. Linux futexes do
exactly this — the fast path is a userspace CAS with no syscall at all, and only
contention enters the kernel.
Say this
"Mutex for mutual exclusion, semaphore for counting or signalling — the real
difference is ownership: only the thread that locked a mutex can unlock it, which
enables priority inheritance and misuse detection, whereas any thread can signal a
semaphore. Condition variables handle 'wait until state changes', and they must
always be waited on in a while loop, because both spurious wakeups
and another thread stealing the item mean the condition can be false again by the
time you re-acquire the lock."
They're not puzzles — they're the three shapes almost every real
concurrency problem takes. Bounded buffer is every queue and thread pool.
Readers-writers is every cache and config store. Dining philosophers is every
multi-lock deadlock you'll ever debug. Learn the shape and you can derive the
solution rather than recall it.
Producer–consumer (bounded buffer)
Producers add, consumers remove, the buffer is size N. Producers must block when
full, consumers when empty, and the buffer itself needs mutual exclusion.
semaphore empty = N; // free slots
semaphore full = 0; // filled slots
mutex m;
ProducerConsumer
wait(empty); // a slot? wait(full); // an item?
wait(m); wait(m);
buffer[in] = item; item = buffer[out];
in = (in+1) % N; out = (out+1) % N;
signal(m); signal(m);
signal(full); // +1 item signal(empty); // +1 slotTwo things to get right, and both are asked:
1. ORDER MATTERS. Acquiring the mutex BEFORE the counting semaphore
deadlocks: a producer holding m blocks on a full buffer, and the
consumer that would drain it can never get m. Resource semaphore
first, then the mutex. Always.
2. The counting semaphores do double duty — they enforce the bound AND
they are the blocking mechanism. No polling anywhere.
Readers–writers
Many readers may read simultaneously; a writer needs exclusive access. The
interesting part is who gets priority, because you cannot have all three of
reader concurrency, no reader starvation, and no writer starvation.
Variant
Rule
Starves
First (reader-priority)
A reader may enter whenever another reader is inside
Writers — a continuous stream of readers blocks writes forever
Second (writer-priority)
A waiting writer blocks new readers
Readers
Third (fair / queued)
FIFO through a turnstile semaphore
Nobody — the usual choice in real libraries
// Reader-priority (shows the mechanism; note it starves writers)
int readcount = 0; mutex rc_mutex; semaphore rw = 1;
ReaderWriter
wait(rc_mutex); wait(rw);
readcount++; ... write ...
if (readcount == 1) wait(rw); // 1st signal(rw);
signal(rc_mutex); // simple, and starvable
... read ...
wait(rc_mutex);
readcount--;
if (readcount == 0) signal(rw); // last one out unlocks
signal(rc_mutex);
The trick: only the FIRST reader acquires rw and only the LAST releases it.
Readers in between just adjust the count.
Real-world note: this is why RWLocks often lose to a plain mutex — the
counter itself is a contended cache line, and under heavy read load the
bookkeeping can cost more than the exclusion you avoided.
Dining philosophers
Five philosophers, five forks between them, each needs both neighbours' forks to
eat. If all five simultaneously pick up their left fork, all five hold one fork and
wait forever for the right. That's a textbook circular wait — and the four standard
fixes map exactly onto the four ways to break deadlock:
Fix
Mechanism
Breaks
Cost
Allow only 4 at the table
A semaphore initialised to 4
Circular wait
Slightly less parallelism; trivially correct
Global fork ordering
Always pick up the lower-numbered fork first
Circular wait
None at runtime. The standard real-world answer.
Asymmetry
Odd philosophers take left first, even take right first
Circular wait
None; a special case of ordering
Atomic both-or-neither
Take both forks under one lock, or none
Hold and wait
Serialises pickup; can starve an unlucky philosopher
The transferable lesson
Global lock ordering is the one to remember, because it is what you actually do
in production: if every thread acquires locks in the same total order, a cycle is
impossible, and it costs nothing at runtime. When you see a multi-lock deadlock in
real code, the fix is almost always "impose an order" — often just documenting
"always take account_lock before ledger_lock".
Deadlock is not the only failure
Starvation: a thread is runnable but never selected — the
system is making progress, just not for them. Livelock: threads
are actively executing and responding to each other but no work completes (two
people stepping aside in a corridor, forever). Both look like a hang and neither
is a deadlock; a cycle detector will find nothing. Randomised backoff is the usual
livelock fix.
11
Deadlock
four conditionspreventionBanker'sdetection
Why it exists
Because threads need more than one thing at a time, and they acquire them one at
a time. Between acquiring the first and the second, someone else can take the
second and want the first. Nothing is broken; everyone is politely waiting for
everyone else. Forever.
The four necessary conditions (Coffman)
All four must hold simultaneously. Break any one and deadlock is impossible —
which is exactly how every prevention strategy works.
Condition
Meaning
How to break it
What that costs
Mutual exclusion
At least one resource is non-shareable
Make resources shareable — lock-free structures, immutable data, per-thread copies
Often impossible; a printer can't be shared
Hold and wait
A thread holding one resource requests another
Acquire everything at once, or release all before requesting more
Poor utilisation, and can starve threads that need many resources
No preemption
Resources can't be forcibly taken
Allow rollback — trylock with timeout, release what you hold, retry
Livelock risk; needs safely-abortable work (databases do this)
Circular wait
A cycle in the wait-for graph
Total ordering on lock acquisition
Almost nothing. This is the practical answer.
The cycle-implies-deadlock rule only holds for single-instance resources. With multiple instances you need the full detection algorithm.
The four strategies
Strategy
Approach
Used by
Prevention
Structurally break one of the four conditions
Real code — lock ordering
Avoidance
Grant a request only if the resulting state is safe (Banker's algorithm)
Essentially nothing real — needs advance knowledge of maximum needs
Detection & recovery
Let it happen, detect the cycle, kill or roll back a victim
Databases — this is what a deadlock victim error is
Ignore it
Assume it's rare; reboot if not
Linux, Windows, and most OSes. The ostrich algorithm.
Say this part out loud — it's the differentiator
General-purpose operating systems deliberately do not prevent deadlock.
Prevention costs utilisation, avoidance needs information nobody has, and detection
plus killing a user process is worse than the rare hang. So the kernel provides the
primitives and pushes correctness to the application. Databases make the opposite
choice, because they can roll back a transaction safely — recovery is free
for them and impossible for the OS. Explaining why the choices differ
reads far better than reciting the four strategies.
Banker's algorithm, worked
Avoidance: before granting a request, check whether some ordering of the remaining
processes could all finish. If yes the state is safe; if not, make
the requester wait even though the resource is free.
Total resources: A=10 B=5 C=7
Allocated Max Need = Max − Allocated
A B C A B C A B C
P0 0 1 0 7 5 3 7 4 3
P1 2 0 0 3 2 2 1 2 2
P2 3 0 2 9 0 2 6 0 0
P3 2 1 1 2 2 2 0 1 1
P4 0 0 2 4 3 3 4 3 1
---------
sum 7 2 5 → Available = (10,5,7) − (7,2,5) = (3,3,2)Find a safe sequence:
Available (3,3,2)
P0 needs (7,4,3) → 7>3, can't run yet
P1 needs (1,2,2) → fits! run P1, it finishes and frees its allocation
Available = (3,3,2) + (2,0,0) = (5,3,2)
P3 needs (0,1,1) → fits! Available = (5,3,2) + (2,1,1) = (7,4,3)
P0 needs (7,4,3) → fits exactly! Available = (7,4,3) + (0,1,0) = (7,5,3)
P2 needs (6,0,0) → fits! Available = (7,5,3) + (3,0,2) = (10,5,5)
P4 needs (4,3,1) → fits! Available = (10,5,5) + (0,0,2) = (10,5,7)
SAFE. Sequence: P1 → P3 → P0 → P2 → P4
Now: P1 requests (1,0,2). Grant it tentatively —
Available becomes (2,3,0), P1's Need becomes (0,2,0).
Re-run the safety check: P1 needs (0,2,0), Available (2,3,0) → fits.
Still safe, so the request is GRANTED.
Why nobody uses this: it needs each process's MAXIMUM future demand up
front, it assumes a fixed resource count, and it is O(n²m) per request.
Know it because it is examined; say why it isn't used because that is
what distinguishes understanding from memorisation.
Safe vs unsafe vs deadlocked
These are three different states and the distinction is examined. Safe:
a completion order exists. Unsafe: no guaranteed order exists —
deadlock is possible, not certain, because processes might not request
their stated maximum. Deadlocked: it has actually happened.
Banker's refuses to enter unsafe states, which is conservative by design.
Say this
"Deadlock needs all four Coffman conditions at once — mutual exclusion, hold and
wait, no preemption, and circular wait — so breaking any one prevents it. In
practice you break circular wait with a global lock ordering, because it costs
nothing at runtime, unlike the others. General-purpose OSes deliberately don't
prevent deadlock at all: prevention wastes resources and detection would mean
killing user processes. Databases do detect and recover, because they can roll a
transaction back safely — which is why you get 'deadlock victim' errors from
Postgres but a permanent hang from a buggy multithreaded program."
12
Beyond locks
atomicsmemory orderinglock-freepriority inversion
Why go further
Locks have costs beyond waiting: they serialise, they invite deadlock, and a
thread that dies or is descheduled while holding one blocks everyone. Atomics and
lock-free structures avoid that — at the price of being much harder to get right.
This topic is where senior interviews go.
Compare-and-swap and the retry loop
// Atomic increment without any lockdo {
old = counter; // read
new = old + 1; // compute
} while (!CAS(&counter, old, new)); // swap only if unchangedIf another thread changed counter in between, CAS fails and we retry with
the fresh value. No update is ever lost.
The guarantees you can offer, weakest to strongest:
BLOCKING a stalled thread can block others indefinitely (locks)
LOCK-FREE at least one thread makes progress system-wide
(a single thread might retry forever — that's allowed)
WAIT-FREE EVERY thread completes in a bounded number of steps
(strongest, rarest, usually needs fetch-and-add not CAS)
Cost: under heavy contention the CAS loop wastes work — many threads spin,
one wins. A well-implemented lock can beat lock-free code when contention
is high, because the losers sleep instead of burning cycles.
The ABA problem
CAS checks whether the value is unchanged, not whether nothing
happened. A pointer reads A; another thread pops
A, pushes B, then pushes A again at the same
address. Your CAS sees A, succeeds, and you link into a node that has
been freed and reused. Fixes: a version-tagged pointer (compare value and
counter, so A-with-tag-1 ≠ A-with-tag-3), hazard
pointers, or epoch-based reclamation. Naming ABA unprompted is a strong signal.
Memory ordering — why your code isn't what runs
Compilers reorder instructions; CPUs execute out of order and buffer stores. Both
preserve single-threaded semantics and neither preserves what another thread
observes. So this famously fails:
Thread AThread B
data = 42; while (!ready) ;
ready = true; print(data); // may print 0!B can observe ready==true before it observes data==42, because the two
stores may become visible in either order. Nothing is "wrong" —
neither thread violated its own sequential semantics.
Orderings, weakest to strongest:
relaxed atomicity only, no ordering. Fine for counters.
acquire / release release-store publishes everything before it;
acquire-load sees everything the releaser published.
This pairing is what you almost always want.
seq_cst a single total order all threads agree on.
Default in C++/Java/Rust. Safest, slowest.
This is why volatile in C/C++ does not make code
thread-safe — it prevents compiler caching in a register but emits no memory
barriers and provides no atomicity. (Java's volatile is different: it
does imply ordering.) It's also why the classic double-checked locking singleton was
broken for years: another thread could see a non-null pointer to a
partially-constructed object.
Priority inversion
Low-priority task L takes a lock.
High-priority task H needs it and blocks.
Medium-priority task M — needing nothing — preempts L.
→ H waits on L, L waits on M, M runs freely.
→ A high-priority task is effectively blocked by a MEDIUM one.
Fixes:
priority inheritance L is temporarily boosted to H's priority while
it holds a lock H wants. (Needs OWNERSHIP —
which is why mutexes can do it and semaphores can't.)
priority ceiling any lock holder immediately runs at the highest
priority of any task that could take that lock.
This is not academic: it nearly lost the Mars Pathfinder mission in 1997.
The rover kept resetting because a high-priority bus task was blocked by a
low-priority meteorological task through a shared mutex, and the watchdog
fired. NASA fixed it by enabling priority inheritance — remotely.
The pragmatic hierarchy
Reach for these in order, and say so: (1) don't share — give
each thread its own copy, or use immutable data; (2) share via a
channel/queue — message passing means no shared mutable state to protect;
(3) use a plain mutex with a documented lock order;
(4) use an atomic for a single counter or flag;
(5) write lock-free code only with a measured need and a
memory model you can defend. Most "we need lock-free" instincts are answered at
step 1 or 2.
Say this
"Lock-free code uses compare-and-swap in a retry loop instead of blocking, so a
descheduled thread can't stall everyone — but it's only lock-free, not wait-free:
one thread can retry indefinitely. Two things bite you. ABA, where a value returns
to its original after intermediate changes and CAS wrongly succeeds — fixed with
version tags or hazard pointers. And memory ordering: the compiler and CPU reorder
stores, so you need acquire-release pairing to guarantee another thread sees your
writes in order. That's also why C's volatile isn't thread safety —
it emits no barriers."