CEUC301 · Unit 3 of 9

Synchronization &
Concurrency Control

Chapter 2 established that the scheduler can switch away from a thread at any point — mid-instruction, if it has to. This chapter is about everything that follows from that one fact: what goes wrong when threads share memory, the real toolkit built to stop it (semaphores, mutexes, monitors, condition variables), what "correct" even means once multiple CPU cores are involved, and the trade-off between locking and never locking at all.

Colour contract Linux — primary OS Windows — secondary OS process-level — a whole process thread-level — one thread of WriteWell

The spine. WriteWell's T_ui and T_save share a counter, edits_logged, used for crash-recovery bookkeeping — the same one from Chapter 1. This chapter adds one more resident: T_flush, a small background thread that drains autosave snapshots out of an in-memory buffer and writes them to disk. Three threads, one process, several different ways their shared memory can go wrong — and, this time, the real tools an OS gives you to stop it, not just a proof-of-concept algorithm.

3.1 · FOUNDATIONS

Race Conditions & the Critical Section Problem

One line of code, two threads, and an edit that vanishes.

edits_logged++ looks atomic — one statement, done in an instant. It isn't. Every real CPU turns it into at least three separate steps: load the current value into a register, add one, write the register back. Chapter 1 already told you why this matters: the scheduler can switch threads between any two instructions, not just between statements.

Two people editing the same tally

Imagine two people sharing one running tally on a whiteboard, each walking up independently to add one to it. Each reads the current number, does the addition in their head, then writes the new number down. If both read before either writes, both compute "old value plus one" — and the second person to write erases the first person's update entirely, even though both genuinely did their job. Nobody made a mistake. The timing did.

Formal definitions

A race condition is exactly this: a situation where the final result of concurrent operations on shared data depends on the precise timing of their execution, rather than being determined by the logic of the program alone. The critical section is the specific piece of code that touches the shared data — for T_ui and T_save, the single line edits_logged++, each time either of them executes it.

Any correct solution to the critical section problem — any scheme for deciding who may be inside their critical section at a given moment — must guarantee three things:

The three requirements
  • Mutual exclusion. No two threads are ever inside their critical sections for the same shared data at the same time.
  • Progress. If no thread is in its critical section, and some thread wants in, that decision can't be postponed forever by threads that don't even want in.
  • Bounded waiting. There's a limit on how many times other threads can enter before a waiting thread gets its turn — no indefinite skipping.
Why all three, not just the first
  • A solution that only guarantees mutual exclusion could still lock everyone out forever (fails progress) — technically safe, uselessly so.
  • A solution with mutual exclusion and progress could still let one thread go first every single time, forever (fails bounded waiting) — safe and eventually-fair only in theory.
  • Every solution in this chapter gets checked against exactly these three.

Figure 1 · watching the update get lost

edits_logged, starting at 41
verified trace
T_ui T_save T_ui LOAD R1=41 edits=41 T_save LOAD R2=41 edits=41 T_ui R1 = 41+1 = 42 edits=42 T_save R2 = 41+1 = 42 edits=42 T_ui STORE edits=R1 (42) edits=42 T_save STORE edits=R2 (42) edits=42 expected 43 (two increments) — got 42: one increment was silently lost
Reading the figure: T_save reads edits_logged before T_ui has written its own update back. Both computed "42" independently and correctly — the bug isn't in either thread's arithmetic, it's that the second write silently erases the first.
Pitfall

"It's just one line, it can't race" is the single most common wrong intuition in this entire unit. Any operation that reads shared data and later writes a value derived from it — increment, decrement, append, even "set flag if not already set" — is a critical section, no matter how short the source code looks. Source-code brevity has nothing to do with whether an operation is atomic in hardware.

Before there was a proper tool: three attempts using only shared variables

It's worth seeing, briefly, what people tried before reaching for the toolkit in Section 3.2 — because every one of those tools exists specifically to make this next part unnecessary.

Three early attempts, and exactly where each one stands
AttemptIdeaVerdict
Lock variableShared boolean lock; spin while true, then set it true before entering.Fails immediately — "check, then set" isn't atomic, so it's racy in exactly the same way edits_logged++ is.
Strict alternationA shared turn variable; each thread waits until it's exactly its turn.Satisfies mutual exclusion, but fails progress: a thread with nothing to do can still block the other one out indefinitely.
Peterson's solutionCombine an intent flag per thread with a politeness turn variable.Actually satisfies all three requirements — provably — but only for exactly two threads, and it's fragile on real modern hardware without extra memory-ordering guarantees (Section 3.3 explains exactly why).
Peterson's solution, for reference (process i, other process j) entry(i): flag[i] = true; turn = j; while (flag[j] && turn == j) { /* spin */ } CS: edits_logged++; exit(i): flag[i] = false;

Hardware eventually offered a cleaner way out: an atomic TestAndSet instruction that reads and sets a lock variable in one indivisible step, closing the lock variable's race directly rather than working around it with a proof. But TestAndSet alone still leaves a thread spinning — burning a full CPU core — for as long as it waits. None of these three give a programmer a reusable, safe, efficient building block. That's precisely the gap Section 3.2 fills.

Practice 3.1

  1. Is x = 5 (assigning a constant, not derived from x's old value) a critical section if two threads could execute it concurrently on the same x?
    Show solution

    No — whichever thread's write happens last simply wins, and x ends up 5 either way, since neither write depends on reading the old value first. A race condition specifically requires the result to depend on timing; here, every possible timing produces the same final value.

  2. Variation. T_ui and T_save both execute edits_logged++ starting from 41, but this time T_ui's three instructions complete fully before T_save starts. What's the final value?
    Show solution

    43. With no interleaving, T_save's LOAD reads the value after T_ui's STORE, so it correctly starts from 42 and produces 43. Sequential execution of the exact same two increments is perfectly safe — the danger was always the interleaving, not the operation itself.

  3. Interpretation. Why does strict alternation satisfy mutual exclusion but fail progress, in one sentence each?
    Show solution

    Satisfies mutual exclusion: turn holds one value at a time, so only one thread's wait loop can exit at once. Fails progress: a thread that doesn't want the critical section can still hold turn at its own value indefinitely, blocking the other thread out even when the critical section is free and wanted.

  4. Synthesis. Peterson's solution and the naive lock variable both use a flag-like variable. What does Peterson's solution add that actually fixes the lock variable's race?
    Show solution

    The lock variable's race comes from "check, then set" not being atomic. Peterson's solution never has a thread check its own intent flag before setting it — each thread unconditionally sets flag[i] = true first, so that write can never race with itself. The turn variable then breaks the remaining tie deterministically: whichever thread's write to it happens last is the one the waiting condition actually reads.

3.2 · SOLUTIONS

Semaphores, Mutexes, Monitors & Condition Variables

A real, reusable tool — not a two-variable proof you re-derive every time.

Section 3.1 ended on a gap: no software trick using only ordinary variables gives you an easy, efficient, reusable way to protect a critical section. Edsger Dijkstra closed that gap in the early 1960s with a single idea that's still how most concurrent code is written today.

The semaphore

Think of a semaphore as a key rack with a counter on it. If the counter is positive, you take a key and the count drops by one. If it's zero, you wait — someone has to return a key first. A semaphore is exactly this, formalised as an integer S with two operations, and critically, both operations are atomic: the hardware or kernel guarantees each one runs as a single indivisible step, so the race from Section 3.1 can't recur inside the semaphore itself.

wait(S) and signal(S) — Dijkstra's P and V wait(S): S = S - 1; if (S < 0) { block the calling thread } signal(S): S = S + 1; if (there is a blocked thread) { wake one }

A binary semaphore is constrained to 0 or 1 and behaves like a lock: initialise it to 1, and wait/signal pairs enforce mutual exclusion directly.

edits_logged++, the semaphore way shared semaphore mutex = 1; wait(mutex); edits_logged++; signal(mutex);

Three lines, versus Peterson's eight, and this version works for any number of threads without a single change — not just two. A counting semaphore is the same construct initialised to any N, modelling a pool of N interchangeable resources rather than a single lock.

Worked example: the bounded buffer

T_save now has company: T_flush, a background thread that drains autosave snapshots out of a small 3-slot in-memory buffer and writes them to disk. This is the classic producer-consumer problem, and it needs three semaphores at once:

Bounded buffer, three semaphores shared semaphore empty = 3; // slots not yet in use shared semaphore full = 0; // slots holding a snapshot shared semaphore mutex = 1; // protects the buffer itself T_save (producer): T_flush (consumer): wait(empty); wait(full); wait(mutex); wait(mutex); put snapshot in buffer; remove snapshot from buffer; signal(mutex); signal(mutex); signal(full); signal(empty);

Figure 2 · two producers' worth of snapshots, one flush

3-slot buffer, starting empty
step 1 of 4
empty3
full0
mutex1
Pitfall — acquisition order matters, or this deadlocks

Swap the order in T_flush to wait(mutex) before wait(full) and try it on an empty buffer: T_flush grabs the mutex, then blocks on fullwhile still holding the mutex. T_save can get past wait(empty) but then blocks on wait(mutex), which T_flush will never release. Neither thread can ever move again. The resource-counting semaphore (empty/full) must always be acquired before the mutual-exclusion semaphore, never after.

Depth — a missing signal() is a silent, permanent bug

Every wait needs a matching signal on every code path, including error handling. Forget one — an early return that skips signal(mutex), say — and the semaphore's count never recovers. There's no crash, no error message: the next thread that calls wait just blocks forever. This class of bug is exactly why languages increasingly favour scoped/RAII-style locking that releases automatically, even on an exception or early return.

Spin, or block? Revisited with a real mutex

A semaphore's wait has the same choice a hand-rolled TestAndSet lock did: when a thread can't proceed, does it spin, or give up the CPU? Reusing Chapter 1's measured context- switch cost settles it numerically.

Break-even: spin cost vs. block cost
drag the slider
Cost of spinning
Cost of blocking (out + back)3.6 µs
Cheaper strategy

Real semaphore implementations block by default (a thread that calls wait on an unavailable semaphore is put to sleep, not spun) — but the underlying mutex protecting the semaphore's own counter is almost always a short-held spinlock, precisely because that critical section is only a few instructions long, deep in spin territory on this chart.

Monitors: mutual exclusion you can't forget

A semaphore is powerful and easy to misuse — nothing stops you from calling wait twice, forgetting signal, or acquiring two semaphores in the wrong order. A monitor, introduced by C.A.R. Hoare and Per Brinch Hansen in the early 1970s, bundles shared data together with the only procedures allowed to touch it, and the language or runtime automatically ensures only one thread executes inside at a time — there's no explicit wait(mutex)/signal(mutex) to forget, because the compiler put it there for you.

Condition variables: waiting for something else to become true

Mutual exclusion alone isn't enough for the bounded buffer — T_flush needs to wait for a specific condition ("the buffer isn't empty"), not just for the lock. A condition variable gives a monitor exactly this: wait(cv) atomically releases the monitor's lock and blocks the thread; signal(cv) wakes (at most) one waiter.

Two possible semantics for what happens right after signal(cv)
SemanticsWhat happens on signalConsequence
HoareControl transfers immediately and directly to the woken thread; the signaller pauses.The woken thread can trust the condition is exactly as it was at the moment of signalling — a plain if is safe.
MesaThe signaller keeps running; the woken thread just becomes ready and competes normally to re-enter later.By the time it actually runs, another thread may have already changed things — it must re-check in a while loop.
Pitfall — why it's always a while loop

Virtually every real system (POSIX threads, Java, and everything built on them) uses Mesa semantics, because Hoare's immediate hand-off is expensive to implement correctly. That means if (buffer_empty) wait(cv); is a real, common bug: between being woken and actually resuming, another thread may have already consumed the item you were signalled about. The correct form is always while (buffer_empty) wait(cv); — check again after waking, every time.

Practice 3.2

  1. A binary semaphore mutex is initialised to 1. Thread A calls wait(mutex) twice in a row without an intervening signal. What happens the second time?
    Show solution

    mutex drops to −1, which is negative, so Thread A blocks itself on its own second call — a self-deadlock. This is exactly the "forgot to structure the code carefully" bug the note above warns about: nothing prevents calling wait twice, and the semaphore has no notion of "who already holds it."

  2. Variation. In the bounded buffer, what would go wrong if empty were initialised to 5 instead of 3, while the actual buffer array still only has 3 slots?
    Show solution

    T_save could be let through wait(empty) up to 5 times before any consumption happens, but the buffer only has room for 3 — the 4th and 5th snapshots would overwrite buffer memory that's already in use. The semaphore's initial value must always match the real physical capacity it's modelling; get that number wrong and the semaphore will happily authorise more concurrent access than the resource can actually support.

  3. Interpretation. A monitor's condition-variable wait call is documented as releasing the monitor's lock before blocking. Why does it have to do both atomically, as one step?
    Show solution

    If releasing the lock and blocking were two separate steps, a signalling thread could acquire the lock and call signal in the gap between them — a signal sent before the waiter is actually registered as waiting is simply lost, and the waiter then sleeps forever waiting for a wake-up that already happened. Making release-and-block one atomic operation is exactly what closes that gap.

  4. Synthesis. Connect Mesa semantics' "recheck in a while loop" requirement back to Chapter 2's scheduler: why can't a woken thread simply assume it will run immediately?
    Show solution

    Being moved from blocked to ready doesn't grant the CPU — Chapter 2 established that a ready thread still has to wait its turn under whatever scheduling algorithm is running. Any other thread that runs first, including another one just woken from the same condition variable, can change the shared state before the original waiter actually gets scheduled. Mesa semantics is really just being honest about a fact Chapter 2 already established: "ready" is not "running."

3.3 · MODELS

Memory Consistency & Visibility Models

T_ui just saw save_complete = true. Is the document actually saved?

Sections 3.1 and 3.2 fixed who gets to run inside a critical section. This section is about a quieter, sneakier problem: even with no race at all on any single variable, two threads on two different CPU cores are not guaranteed to see each other's writes in the order those writes actually happened.

A note on the fridge, read from an old photo

If you write "dinner's ready" on a sticky note and put it on the fridge, your housemate reading it a minute later just works — you're both looking at the same physical note. Now imagine your housemate is looking at a photo of the fridge from their phone instead, one that syncs every so often. They might see the note appear before the food is actually finished cooking, or the food finished before the note appears, depending entirely on when the photo happened to sync. Multiple CPU cores, each with their own cache, are exactly this: each core has its own "photo" of memory, refreshed on its own schedule unless something forces a sync.

Sequential consistency, formally

The model every programmer intuitively assumes is sequential consistency, formalised by Leslie Lamport in 1979: the result of any execution is the same as if the operations of all the processors were executed in some sequential order, and the operations of each individual processor appear in that sequence in the order specified by its own program. In plain terms: writes from any one thread stay in the order that thread issued them, and every other thread agrees on some single global ordering of everyone's operations.

Pitfall

Real hardware does not give you sequential consistency for free. Store buffers let a core hold onto its own writes briefly before they reach other cores; compilers reorder instructions that look independent to them; out-of-order execution can let a later read finish before an earlier write is even visible outside the core that issued it. None of this is a bug — it's how modern CPUs get their speed — but it means "I wrote A then B" does not guarantee "everyone else sees A before B."

Worked example: the flag that lied

T_save writes the actual autosave bytes to a buffer, then sets save_complete = true so T_ui can show a "Saved" indicator. T_ui's code checks the flag, and if it's true, reads the buffer to confirm. Both threads look correct in isolation. Running on two different cores with no synchronization between them, they aren't.

Figure 3 · the same two writes, two legal orders

Program order vs. what another core is allowed to see
no lock, no barrier
T_save's program order (what the code says) 1. write save_data the actual bytes 2. write save_complete = true the “done” flag what T_ui's core can legally observe (no barrier, no lock) 2. save_complete = true visible first 1. save_data write visible second T_ui reads here: flag says done, but save_data is still stale
Reading the figure: nothing here is a compiler bug or a broken CPU — both orderings are legal under a relaxed memory model. T_ui can observe save_complete flip to true and, in that same instant, still read stale bytes from the save buffer — a false "Saved" indicator on a document that isn't actually saved yet.

The fix: the same tools from Section 3.2, used correctly

Wrapping both of T_save's writes — and T_ui's read — in the mutex from Section 3.2 fixes this, not incidentally but by design: real mutex and semaphore implementations include the necessary memory barriers as part of acquiring and releasing the lock. Get the mutual exclusion right, using the real primitives, and the visibility problem is solved as a side effect. Language-level tools work the same way: C++'s atomic types with an explicit memory_order, or the humble volatile keyword in some languages, exist specifically to tell the compiler and CPU "don't reorder across this point."

Depth — not all hardware is equally relaxed

x86 implements Total Store Order (TSO): quite strong, and the specific reordering in Figure 3 (a later store becoming visible before an earlier one) is one of the few things it actually permits. ARM and POWER implement much weaker models, allowing far more reordering by default — code that "happens to work" on x86 without proper synchronization can fail visibly on ARM. This is precisely why Peterson's solution (Section 3.1) is described as fragile on modern hardware: its proof assumes sequential consistency, which x86 mostly provides in practice but weaker architectures do not guarantee at all.

Practice 3.3

  1. Two threads run on the same single CPU core (no real parallelism, just time-sliced). Does the reordering problem in Figure 3 still apply?
    Show solution

    Much less so. A single core executing instructions from multiple threads (via context switches) still sees its own writes in program order from its own perspective, and typically flushes its store buffer on a context switch. The dangerous reordering in Figure 3 is specifically a multi-core problem, where two cores have physically separate caches and no inherent reason to agree on ordering without explicit synchronization.

  2. Variation. If T_save used a mutex around its two writes, but T_ui read save_complete and the buffer without acquiring any lock at all, is the visibility problem fixed?
    Show solution

    Not necessarily. A mutex's guarantees are strongest when both sides of a shared access go through it — the barrier on the writer's release doesn't help a reader that never synchronizes with it at all. Correct fixes require the reader to also go through a corresponding acquire (the same mutex, or a matching atomic load with the right memory order), not just the writer using one.

  3. Interpretation. A developer says "this bug only happens on the ARM build, never on x86 in years of testing." What does that suggest about the bug?
    Show solution

    It's very likely a missing-synchronization / reordering bug of exactly the kind this section covers — x86's stronger TSO model happens to forbid the specific reordering the buggy code depends on not happening, while ARM's weaker model permits it. "Works on x86" is not evidence of correctness here; it's evidence the code was never actually safe, just running on hardware forgiving enough to hide it.

  4. Synthesis. Explain why Section 3.2's monitors don't need a separate "memory consistency" discussion of their own.
    Show solution

    A correctly implemented monitor's automatic locking already includes the same memory barriers a manually-used mutex would need — entering and leaving the monitor's automatic critical section provides the acquire/release ordering for free. The visibility problem in this section is precisely what you get when someone tries to hand-roll synchronization with a plain flag instead of using one of Section 3.2's real primitives.

3.4 · DESIGNS

Lock-Based vs. Lock-Free Designs

What if a thread never had to wait for anyone at all?

Every tool in Section 3.2 works the same way: if you can't proceed, you wait — spinning or blocked — for someone else to finish. That waiting is exactly where deadlock, priority inversion, and the convoy effect come from. A different family of designs asks: what if nothing ever waited?

What locks cost, beyond the wait itself

  • Deadlock. Two threads each holding a lock the other wants, forever — Unit 4 gives this its own full treatment.
  • Priority inversion. A low-priority thread holding a lock blocks a high-priority thread that needs it — directly undermining the real-time guarantees Chapter 2's EDF and RMS worked so hard to prove.
  • The convoy effect, again. If the thread holding a lock is preempted mid-critical-section (Chapter 2), every other thread waiting on that lock is stuck too, no matter how ready they otherwise are.

Lock-free: retry instead of wait

A lock-free algorithm never blocks. Instead, it reads the current state, computes a new state, and tries to install it with a single atomic Compare-And-Swap (CAS) instruction: "update this memory location to the new value, but only if it still holds the value I originally read." If some other thread got there first, the CAS simply fails — harmlessly — and the thread tries again with a fresh read.

edits_logged++, lock-free do { old = edits_logged; new = old + 1; } while (CAS(&edits_logged, old, new) fails);

The guarantee is subtle and worth stating precisely: lock-free means the system as a whole always makes progress — some thread's CAS succeeds every time there's contention — not that any particular thread is guaranteed to succeed on any particular try.

Worked example: the exact interleaving that broke Section 3.1, revisited

Same race, CAS instead of plain load-add-store
StepThreadActionedits_logged
1T_uireads old = 4141
2T_savereads old = 41, computes new = 4241
3T_saveCAS(expect 41, set 42) — succeeds42
4T_uiCAS(expect 41, set 42) — fails, 41≠4242
5T_uiretries: reads old = 42, computes new = 4342
6T_uiCAS(expect 42, set 43) — succeeds43

Final value: 43, correct — both increments counted. This is the exact interleaving that produced a lost update in Section 3.1's Figure 1; CAS doesn't prevent the collision, it detects it and forces a retry instead of silently overwriting.

The ABA problem

CAS checks whether a value is unchanged, not whether it's been untouched. If a value leaves A, becomes something else, and returns to A before a delayed CAS checks it, the CAS sees "still A" and proceeds — even though the underlying structure changed meaningfully in between.

A lock-free stack, A → B → C, popped by two threads
StepThread 1Thread 2Stack
1Reads head=A, remembers "next is B"A→B→C
2(preempted)Pops AB→C
3(preempted)Pops B (freed)C
4(preempted)Pushes A backA→C
5CAS(head: expect A, set B) — succeeds (head is A again!) B — a freed, stale node

Thread 1's CAS never noticed anything was wrong — head really was A at the instant it checked. The stack now points at B, which Thread 2 already freed. The standard fixes: attach a version counter to the pointer that increments on every change, so CAS compares pointer and tag together (a stale match can't slip through); or hazard pointers, where threads publish which nodes they're currently touching so those nodes can't be freed out from under them.

Depth — a hierarchy of how strong "doesn't block" really is

Lock-free is one point on a scale. Obstruction-free only guarantees progress for a thread running with no contention at all — the weakest useful guarantee. Lock-free guarantees the system makes progress under contention, but not any one thread. Wait-free guarantees every thread finishes in a bounded number of steps no matter what every other thread does — the strongest, and by far the hardest and most expensive to actually achieve.

Pitfall

Lock-free is not a strictly better replacement for locks. Under low contention, a retry loop can cost more than a simple lock would have, since most attempts succeed on the first try anyway and the extra CAS machinery is pure overhead. Lock-free code is also substantially harder to prove correct — the ABA problem above is one of several subtle failure modes with no analogue in straightforward lock-based code. Reach for it when priority inversion or deadlock risk is unacceptable (real-time systems, again), not as a default.

Practice 3.4

  1. A thread's CAS fails three times in a row before succeeding on the fourth attempt. Has the algorithm violated the lock-free guarantee?
    Show solution

    No. Lock-free only promises the system makes progress on every contended attempt — some thread succeeds each time — not that any specific thread succeeds quickly. Three failed retries followed by a fourth success is exactly what "lock-free, not wait-free" looks like in practice.

  2. Variation. If edits_logged's CAS-based increment were used instead by five threads simultaneously rather than two, does the correctness argument in the worked example still hold?
    Show solution

    Yes, unchanged. Each thread's CAS still only succeeds if the value it read hasn't changed since; with five threads instead of two, more retries will typically be needed under contention, but the same detect-and-retry logic handles any number of colliding threads without modification — unlike Peterson's solution, which was explicitly limited to two.

  3. Interpretation. A lock-free stack implementation using tagged pointers is reported to "waste" a few bits of every pointer on a version counter. Why is this considered an acceptable trade-off?
    Show solution

    Those bits are the entire fix for the ABA problem: without them, a CAS genuinely cannot distinguish "unchanged" from "changed and changed back," which can silently corrupt the structure. A few bits of overhead per pointer is cheap compared to a data structure that is occasionally, unpredictably wrong.

  4. Synthesis. Explain why a lock-free algorithm still needs the memory-consistency guarantees from Section 3.3, even though it never uses a mutex.
    Show solution

    CAS is atomic with respect to the single memory location it operates on, but a thread's read of the "old" value and its use of any other related data still needs proper ordering guarantees to avoid the reordering hazard in Figure 3. Atomicity of one operation and consistent visibility of everything around it are separate problems — lock-free code solves the first without automatically solving the second.

WRAP-UP

Cheat Sheet & Self-Test

Everything above, compressed to what you'd want on the way into an exam.

3.1 Race conditions

Race condition: result depends on timing, not just logic. Critical section: code touching shared data.

Three requirements: mutual exclusion, progress, bounded waiting.

Lock variable fails (racy check-then-set). Strict alternation fails progress. Peterson's works, 2 threads only, fragile on real hardware.

3.2 Semaphores & monitors

wait(S): S−−, block if S<0. signal(S): S++, wake a waiter.

Bounded buffer: acquire empty/full before mutex, always — wrong order deadlocks.

Monitor = automatic mutual exclusion. Condition variable wait in a while loop (Mesa semantics), never if.

3.3 Memory consistency

Sequential consistency (Lamport): global order consistent with each thread's own program order. Intuitive, not free.

Real hardware reorders (store buffers, compiler, out-of-order exec). A flag can go visible before the data it guards.

Fix: real mutexes/atomics include the needed barriers. x86 (TSO) stronger than ARM/POWER (weak).

3.4 Lock-free

CAS: update only if unchanged since read; retry on failure. System-wide progress, not per-thread.

ABA: value returns to A after changing — CAS can't tell. Fix: tagged pointers, hazard pointers.

Hierarchy: obstruction-free < lock-free < wait-free. Avoids deadlock/priority inversion; costs more under low contention.

Mixed self-test

Deliberately not grouped by section — your exam won't be either.

  1. Two threads both execute y = 7 on a shared variable y, concurrently. Is this a race condition?
    Show solution

    No. Neither write depends on reading y's old value, so every possible interleaving produces the same final result (7). (3.1)

  2. In the bounded buffer, why must wait(full) or wait(empty) always be acquired before wait(mutex), never after?
    Show solution

    Acquiring the mutex first and then blocking on full/empty leaves the mutex held while asleep — the other thread can never acquire the mutex to make the progress that would eventually satisfy the wait, producing a deadlock. (3.2)

  3. Why must a condition variable's wait always be inside a while loop rather than an if, under Mesa semantics?
    Show solution

    Between being woken and actually resuming, another thread may have already changed the state the condition depended on. Only re-checking after waking (in a loop) catches that; an if trusts a condition that may no longer hold. (3.2)

  4. A junior developer argues "if my code works correctly every time I test it on my x86 laptop, it's correct." What's wrong with that reasoning, specifically regarding this chapter?
    Show solution

    x86's Total Store Order is one of the stronger memory models; code that only "happens to work" because of guarantees TSO provides can fail on weaker architectures (ARM, POWER) that permit reorderings TSO forbids. Passing tests on one architecture is not evidence of being correctly synchronized. (3.3)

  5. Under lock-free CAS, a thread's update attempt fails. What does that failure actually mean?
    Show solution

    It means some other thread successfully changed the value between this thread's read and its CAS — not an error, just a signal to re-read the current value and try the whole operation again. The system made progress (someone else's update landed); this one thread simply has to retry. (3.4)

  6. Explain the ABA problem in one or two sentences, without using the letters A, B, or C.
    Show solution

    A compare-and-swap only checks whether a memory location holds the same value it did when originally read — it cannot tell "unchanged" apart from "changed and then changed back to the same value." If the underlying structure was meaningfully modified and reverted in between, the CAS can succeed on stale assumptions and corrupt the structure. (3.4)

  7. Why does Chapter 2's real-time scheduling material (EDF, RMS) come up again in this chapter's discussion of lock-free designs?
    Show solution

    Priority inversion — a low-priority thread holding a lock blocking a high-priority thread that needs it — directly undermines the deadline guarantees EDF and RMS are built on. Lock-free designs have no lock to invert priority around, which is a genuine, practical reason real-time systems favour them despite the added design complexity. (3.4, callback to Ch2)

  8. A semaphore's wait and signal operations are described as atomic. What would go wrong if they weren't?
    Show solution

    The semaphore's own internal counter update ("decrement, then check if negative" or "increment, then check for a waiter") would itself become a critical section subject to exactly the race condition from Section 3.1 — the very problem the semaphore exists to solve would simply recur one layer down, inside the tool meant to prevent it. (3.1, 3.2)

Further reading

  • Stallings, Operating Systems: Internals and Design Principles, 9th ed., Ch. 5 & 6. This course's primary text; closest match to this chapter's structure across all four sections.
  • Silberschatz, Galvin & Gagne, Operating System Concepts, 10th ed., Ch. 6 & 7. The standard reference formulation of semaphores, monitors, and the classic synchronization problems.
  • Dijkstra, E.W., "Cooperating Sequential Processes," 1965/1968. The original semaphore paper, for the source rather than the textbook summary.
  • Hoare, C.A.R., "Monitors: An Operating System Structuring Concept," CACM, 1974. The paper that introduced monitors, used directly in Section 3.2.
  • Lamport, L., "How to Make a Multiprocessor Computer That Correctly Executes Multiprocess Programs," 1979. The formal definition of sequential consistency used in Section 3.3.
  • Michael, M. & Scott, M., "Simple, Fast, and Practical Non-Blocking and Blocking Concurrent Queue Algorithms," 1996. The classic lock-free queue design and a clear treatment of the ABA problem, used in Section 3.4.

Before this chapter — Chapter 1 (kernel-level threads share one address space, which is why T_ui and T_save can even see the same edits_logged) and Chapter 2 (the scheduler can preempt a thread between any two instructions, which is why the race in Figure 1 is possible at all, and whose real-time scheduling material this chapter's priority-inversion discussion depends on).

Where this goes next — Unit 4, Deadlock Modeling and System-level Handling. This chapter's tools stop threads from corrupting shared data, but they open a new door: a thread that correctly waits for a lock it will never get is just as stuck as one racing on unprotected memory. Unit 4 asks what makes a deadlock possible, how to prevent or avoid one before it happens, and what to do about it once it has — including the closely related, and often confused, problem of starvation.