OSOperating Systems

Block B · Topics 8–12

Concurrency

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 A                    Thread 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                                          6

Two 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:

  1. Mutual exclusion. At most one thread inside the critical section at a time.
  2. Progress. If nobody is inside and someone wants in, one of the waiting threads must get in — no mutual blocking, no deadlock.
  3. 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.

PrimitiveAnswersKey propertyUse for
Mutex"May I touch this?"Ownership. Only the locking thread may unlock. Binary.Protecting a data structure
Counting semaphore"Is there one left?"No ownership — any thread may signal. Counts.Resource pools, bounded buffers, limiting concurrency
Binary semaphoreSame as mutex, almostNo ownership, so no recursion checks and no priority inheritanceSignalling 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
MonitorA language construct bundling a mutex + condition variables with the dataJava 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 writerRead-heavy shared state — but watch writer starvation
Barrier"Has everyone arrived?"All N threads block until all N reach itPhased 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."

10

The classic problems

producer-consumerreaders-writersdining philosophers
Why these three

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;

Producer                       Consumer
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 slot

Two 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.

VariantRuleStarves
First (reader-priority)A reader may enter whenever another reader is insideWriters — a continuous stream of readers blocks writes forever
Second (writer-priority)A waiting writer blocks new readersReaders
Third (fair / queued)FIFO through a turnstile semaphoreNobody — the usual choice in real libraries
// Reader-priority (shows the mechanism; note it starves writers)
int readcount = 0;  mutex rc_mutex;  semaphore rw = 1;

Reader                              Writer
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:

FixMechanismBreaksCost
Allow only 4 at the tableA semaphore initialised to 4Circular waitSlightly less parallelism; trivially correct
Global fork orderingAlways pick up the lower-numbered fork firstCircular waitNone at runtime. The standard real-world answer.
AsymmetryOdd philosophers take left first, even take right firstCircular waitNone; a special case of ordering
Atomic both-or-neitherTake both forks under one lock, or noneHold and waitSerialises 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.

ConditionMeaningHow to break itWhat that costs
Mutual exclusionAt least one resource is non-shareableMake resources shareable — lock-free structures, immutable data, per-thread copiesOften impossible; a printer can't be shared
Hold and waitA thread holding one resource requests anotherAcquire everything at once, or release all before requesting morePoor utilisation, and can starve threads that need many resources
No preemptionResources can't be forcibly takenAllow rollback — trylock with timeout, release what you hold, retryLivelock risk; needs safely-abortable work (databases do this)
Circular waitA cycle in the wait-for graphTotal ordering on lock acquisitionAlmost nothing. This is the practical answer.
Resource-allocation graph — a cycle means deadlock (with single instances) P1 P2 R1 R2 R1 → P1 assigned P1 → R2 requesting R2 → P2 assigned P2 → R1 requesting Single-instance resources: cycle ⟺ deadlock. Detection is just cycle detection. Multiple instances per resource: a cycle is NECESSARY but not sufficient — another holder may release and break it.
The cycle-implies-deadlock rule only holds for single-instance resources. With multiple instances you need the full detection algorithm.

The four strategies

StrategyApproachUsed by
PreventionStructurally break one of the four conditionsReal code — lock ordering
AvoidanceGrant a request only if the resulting state is safe (Banker's algorithm)Essentially nothing real — needs advance knowledge of maximum needs
Detection & recoveryLet it happen, detect the cycle, kill or roll back a victimDatabases — this is what a deadlock victim error is
Ignore itAssume it's rare; reboot if notLinux, 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 lock
do {
    old = counter;                       // read
    new = old + 1;                       // compute
} while (!CAS(&counter, old, new));  // swap only if unchanged

If 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 A              Thread 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."