The Critical Section Problem
Problem Requirements
Read aloud is unavailable in this browser.
The Three Pillars of Correct Synchronization
Throughout this module, we've discussed the critical section problem and the structure of solutions (entry, critical, exit, and remainder sections). But how do we know if a proposed solution is correct? What criteria must it satisfy?
This question is not merely academic. Many solutions that appear correct on first inspection contain subtle flaws that emerge only under specific timing conditions. To rigorously evaluate synchronization mechanisms, computer scientists have formalized three requirements that any correct solution must satisfy: Mutual Exclusion, Progress, and Bounded Waiting.
These requirements are the litmus test for correctness. A solution that fails even one of them is fundamentally flawed and cannot be safely used in production systems.
By the end of this page, you will understand each of the three requirements in rigorous detail, see examples of violations, learn how to prove that a solution satisfies (or violates) each requirement, and understand why all three are necessary—not just desirable—for correct synchronization.
Requirement 1: Mutual Exclusion
Formal Definition:
If process P_i is executing in its critical section, then no other process P_j (where j ≠ i) can be executing in its critical section for the same shared resource.
This is the primary requirement—the very reason critical sections exist. Without mutual exclusion, the entire purpose of synchronization is defeated.
What Mutual Exclusion Guarantees:
- At any instant in time, at most one process is executing code within its critical section
- If one process is modifying shared data, no other process can simultaneously read or write that data
- Race conditions that corrupt shared data are prevented
- The invariants of shared data structures are never violated from concurrent access
Note that the requirement is 'at most one' process, not 'exactly one.' If no process wants the critical section, zero processes are in it—and that's perfectly acceptable. Mutual exclusion doesn't require that someone always be in the CS; it requires that no more than one be there simultaneously.
Visualizing Mutual Exclusion:
Consider a timeline where process executions are shown as bars. Mutual exclusion means the CS bars never overlap:
Mutual Exclusion Violation Example:
The following algorithm violates mutual exclusion. Both processes can end up in the CS simultaneously:
12345678910111213141516171819202122232425262728293031323334353637383940414243444546
// BROKEN: This algorithm violates mutual exclusionint turn = 0; // 0 or 1 void enter_cs(int process_id) { while (turn != process_id) { // Wait until it's my turn } // Enter critical section} void exit_cs(int process_id) { turn = 1 - process_id; // Give turn to other process} // WHY IT'S BROKEN:// Consider the scenario where process 0 runs first:// 1. Process 0: turn = 0, so while-loop exits. In CS.// 2. Process 0: Finishes CS. Sets turn = 1.// 3. Process 1: turn = 1, so while-loop exits. In CS.// 4. Process 1: Finishes CS. Sets turn = 0.// 5. Process 0: turn = 0, enters CS again.//// Wait - this seems to work! Where's the violation?//// The violation is PROGRESS, not mutual exclusion.// But consider if turn is initially 0 and ONLY process 1 wants CS:// Process 1 waits forever because turn != 1// This is strict alternation - violates progress, not mutex. // Here's an actual MUTEX VIOLATION:int lock = 0; void broken_enter(int pid) { while (lock == 1) { // Wait while locked } lock = 1; // Claim the lock - NOT ATOMIC WITH THE CHECK!} // VIOLATION SCENARIO:// 1. Process A: reads lock = 0, exits while-loop// 2. [Context switch before setting lock = 1]// 3. Process B: reads lock = 0, exits while-loop// 4. Process A: sets lock = 1, enters CS// 5. Process B: sets lock = 1, enters CS// BOTH ARE IN THE CS! Mutual exclusion violated!Mutual exclusion violations almost always stem from a gap between checking a condition and acting on it. If the check and the action are not atomic, another process can slip through between them. This is why hardware atomic instructions (test-and-set, compare-and-swap) are essential—they make the check-and-act indivisible.
Requirement 2: Progress
Formal Definition:
If no process is executing in its critical section and one or more processes wish to enter their critical sections, then the selection of the process that will enter the critical section next cannot be postponed indefinitely. Only processes that are not executing in their remainder sections can participate in this decision.
Progress ensures that the system can make forward movement—that work actually gets done. A system that never violates mutual exclusion could still be useless if it also never lets anyone in!
What Progress Guarantees:
- When the CS is free and at least one process wants it, someone will eventually enter
- The decision cannot be deferred forever
- Processes that aren't interested (in their remainder section) don't block those that are
- The system doesn't deadlock on an empty CS
| Clause | Meaning | Implication |
|---|---|---|
| "No process is in CS" | The critical section is currently unoccupied | We're not waiting for someone to leave |
| "Some processes wish to enter" | At least one process is in its entry section | There is unfulfilled demand for CS access |
| "Selection cannot be postponed indefinitely" | Eventually, one of the waiting processes will enter | Deadlock on empty CS is impossible |
| "Only non-remainder processes participate" | A process doing other work doesn't block the decision | Disinterested processes can't cause starvation |
Progress Violation Example:
The "strict alternation" algorithm violates progress even though it satisfies mutual exclusion:
123456789101112131415161718192021222324252627282930313233343536373839404142
// BROKEN: This algorithm violates progress (strict alternation)int turn = 0; // 0 = P0's turn, 1 = P1's turn void process_0_entry(void) { while (turn != 0) { // Wait for my turn } // Enter CS} void process_0_exit(void) { turn = 1; // P1's turn next} void process_1_entry(void) { while (turn != 1) { // Wait for my turn } // Enter CS} void process_1_exit(void) { turn = 0; // P0's turn next} // PROGRESS VIOLATION SCENARIO:// Initial: turn = 0// 1. P0 wants CS, enters (turn = 0, loop exits)// 2. P0 finishes, exits, sets turn = 1// 3. P0 immediately wants CS again (short remainder)// 4. P0 calls entry: while (turn != 0)... turn is 1, so P0 WAITS// 5. But P1 is in its remainder section doing something else// 6. CS is EMPTY, P0 WANTS it, but P0 is BLOCKED!//// Why this violates progress:// - No process is in CS (empty)// - P0 wishes to enter (in entry section)// - Yet P0 cannot enter - selection IS being postponed// - P1 is in remainder, so shouldn't block P0's decision//// The algorithm forces alternation even when one process// doesn't want the CS - this is a progress violation.How Peterson's Algorithm Satisfies Progress:
Peterson's algorithm avoids strict alternation by using intention flags in addition to the turn variable:
1234567891011121314151617181920212223242526272829303132
// Peterson's: Satisfies progressint flag[2] = {0, 0}; // Intention flagsint turn; void enter(int i) { int j = 1 - i; flag[i] = 1; // I want in turn = j; // But you go first if you want while (flag[j] == 1 && turn == j) { // Wait only if: // - Other process wants in (flag[j] == 1), AND // - It's their turn (turn == j) }} void exit(int i) { flag[i] = 0; // I'm done, no longer interested} // WHY PROGRESS IS SATISFIED:// Case: CS is empty, P0 wants in, P1 is in remainder//// 1. P1 is in remainder, so flag[1] = 0 (exit set it to 0)// 2. P0 calls enter: sets flag[0] = 1, turn = 1// 3. P0 checks while-loop: flag[1] == 0!// 4. First part of AND is false, loop exits immediately// 5. P0 enters CS without waiting!//// The key insight: The flag variable captures INTENTION.// A process in remainder has flag = 0 (not interested).// An interested process doesn't wait for disinterested ones.Most correct algorithms use some form of 'intention flag'—a mechanism for processes to announce 'I want the CS.' This allows the algorithm to distinguish between 'the CS is free because no one wants it' (progress is trivially satisfied) and 'the CS is free but others are waiting' (need to select one). Without intention flags, you get strict alternation or worse.
Requirement 3: Bounded Waiting
Formal Definition:
There must exist a bound, or limit, on the number of times that other processes are allowed to enter their critical sections after a process has made a request to enter its critical section and before that request is granted.
Bounded waiting prevents starvation—the situation where a process waits indefinitely while other processes repeatedly enter the CS.
What Bounded Waiting Guarantees:
- Every process that requests CS access will eventually get it
- There's a limit on how many times a process can be "skipped over"
- Fairness: no process can repeatedly jump the queue indefinitely
- The system is starvation-free for CS access
Important Distinctions:
Progress vs. Bounded Waiting:
- Progress says: "Someone will enter when the CS is free"
- Bounded waiting says: "Every waiting process will eventually be that someone"
A solution could satisfy progress but violate bounded waiting if it always picks the same process from multiple waiting processes. Progress ensures some process enters; bounded waiting ensures every requesting process enters eventually.
The Bound:
The "bound" in bounded waiting is a maximum number of CS entries by other processes. For example:
- Simple test-and-set spinlock: No bound (can starve)
- Ticket lock: Bound = n-1 (where n is number of processes; each other process enters at most once)
- Peterson's algorithm (2 processes): Bound = 1 (the other process enters at most once)
12345678910111213141516171819202122232425262728293031323334353637
// BROKEN: Simple spinlock violates bounded waitingint lock = 0; // Assume test_and_set is atomicint test_and_set(int* target) { int old = *target; *target = 1; return old;} void enter(void) { while (test_and_set(&lock) == 1) { // Spin until we get the lock }} void exit(void) { lock = 0;} // BOUNDED WAITING VIOLATION SCENARIO:// Consider 3 processes: A, B, C// // 1. A holds lock, B and C are spinning// 2. A releases lock (sets lock = 0)// 3. B and C race to acquire// 4. A immediately tries to re-acquire (short remainder)// 5. A wins the race (might be on same CPU, faster cache access)// 6. B and C still spinning// 7. Repeat: A releases, A re-acquires, B and C still waiting//// There is NO BOUND on how many times A can enter while B waits!// B could wait forever - this is STARVATION.//// WHY: TAS gives no ordering guarantee. The fastest to respond wins.// On NUMA systems, processes on the same chip as the lock variable// have systematically lower latency and can dominate the lock.How Ticket Locks Satisfy Bounded Waiting:
Ticket locks enforce FIFO ordering, guaranteeing bounded waiting:
123456789101112131415161718192021222324252627282930313233343536373839404142
// Ticket Lock: Satisfies bounded waiting (FIFO fair)volatile int next_ticket = 0; // Next ticket to give outvolatile int now_serving = 0; // Current ticket being served void enter(void) { int my_ticket = atomic_fetch_add(&next_ticket, 1); // Get unique ticket while (now_serving != my_ticket) { // Wait for my number to be called } // Enter CS} void exit(void) { now_serving++; // Call next ticket number} // WHY BOUNDED WAITING IS SATISFIED:// Suppose P_i gets ticket k and is waiting.// - Tickets 0 through k-1 must be served before ticket k// - Each ticket corresponds to one CS entry// - Bound = k = number of processes that requested before P_i// - At most (n-1) other processes can enter before P_i//// PROOF:// 1. All tickets are unique (atomic fetch-add)// 2. Tickets are served in strict numerical order// 3. No process can "skip" to an earlier ticket// 4. Therefore, every waiting process WILL be served// 5. The wait is bounded by the number of tickets ahead//// EXAMPLE:// P0 gets ticket 0, enters CS// P1 gets ticket 1, waits (now_serving = 0)// P2 gets ticket 2, waits (now_serving = 0)// P0 exits, now_serving = 1// P1's wait ends, enters CS (exactly 1 other entry while P1 waited)// P0 re-requests, gets ticket 3, waits! (behind P2)// P1 exits, now_serving = 2// P2 enters (exactly 1 other entry while P2 waited)// ...// Everyone gets served fairly.Starvation can occur even when everything 'looks fair.' On NUMA systems, processes close to the lock variable respond faster when it's released. On systems with priority scheduling, lower-priority processes might be repeatedly preempted just before acquiring the lock. Even with test-and-set, starvation requires specific conditions that might be rare—but 'rare' is not 'impossible.' Production systems must guarantee bounded waiting.
Why All Three Requirements Are Necessary
Some might wonder if all three requirements are truly necessary, or if satisfying one or two might be sufficient. The answer is unequivocal: all three are independently necessary, and none implies the others.
Let's examine what happens when each is violated in isolation:
| Violation | What Happens | Example System Failure |
|---|---|---|
| Mutex violation (Progress & BW satisfied) | Multiple processes in CS simultaneously | Bank account debited twice; data structure corruption; security breach |
| Progress violation (Mutex & BW satisfied) | System deadlocks on empty CS; no work done | Thread A waits for B's permission, B is doing unrelated work forever |
| BW violation (Mutex & Progress satisfied) | Some processes starved indefinitely | 99% of requests served promptly; 1% wait forever; support tickets pile up |
Solutions That Satisfy Some But Not All:
- •Mutex ✅, Progress ❌, BW ❌ (Strict Alternation) — Processes must alternate; one process wanting the CS twice is stuck waiting for the uninterested other. Bounded waiting would also be satisfied if P1 never wanted CS, P0 waits forever (effectively infinitely many "skips" by P1 doing nothing).
- •Mutex ✅, Progress ✅, BW ❌ (Simple Spinlock) — Works until contention is high; under heavy load, some unlucky process might never win the race to acquire the lock. System 'works' but is unfair.
- •Mutex ❌, Progress ✅, BW ✅ (No Synchronization) — Everyone enters whenever they want; perfectly fair (everyone gets in immediately!), but complete chaos. Data corruption guaranteed.
- •All Three ✅ (Peterson, Ticket Lock, Correct Mutex) — Correct solutions. Each request is eventually served. Multiple processes never in CS together. System is both correct and fair.
In practice, mutual exclusion is considered most critical (incorrect results are unacceptable), followed by progress (deadlock is immediately visible), followed by bounded waiting (starvation may take time to manifest and affect only some users). However, production-quality synchronization primitives must satisfy all three—there's no excuse for leaving any out.
Proving Requirements Formally
How do we prove that an algorithm satisfies these requirements? This is a crucial skill for understanding and designing synchronization mechanisms. Let's walk through formal proofs for Peterson's Algorithm.
Peterson's Algorithm (Recap):
1234567891011121314151617
// Peterson's Algorithmint flag[2] = {0, 0}; // flag[i] = 1 means process i wants to enterint turn; // Whose turn it is if both want to enter void enter(int i) { int j = 1 - i; // The other process flag[i] = 1; // Line 1: Announce intention turn = j; // Line 2: Give priority to other while (flag[j] == 1 && turn == j) { // Line 3: Busy wait // Wait while other wants in AND it's other's turn } // Line 4: Enter CS} void exit(int i) { flag[i] = 0; // Line 5: Clear intention}Proof of Mutual Exclusion:
We prove by contradiction. Assume both P0 and P1 are in their critical sections simultaneously.
Setup:
- Both are past Line 3 (the while-loop), so they both exited the loop
- For P0 to exit the loop: either
flag[1] == 0ORturn == 0 - For P1 to exit the loop: either
flag[0] == 0ORturn == 1
Case Analysis:
Case 1: One of them saw the other's flag as 0
- If P0 saw
flag[1] == 0, then P1 hadn't yet executed Line 1 - But P1 is in CS, meaning P1 finished Line 1 (set
flag[1] = 1) - For P0 to see
flag[1] == 0, P0's read must have happened before P1's write - But then P0 entered CS first, and when P1 later sets flag[1] and checks, P1 would see flag[0] = 1
- P1's while-loop:
flag[0] == 1 && turn == 0— if turn == 0, P1 waits - So P1 can only proceed if turn == 1
- But if P0 entered first, P0 set turn = 1 (Line 2) before entering
- So turn == 1 when P1 checks... and P1 enters? Let's check:
- P1 sees flag[0] == 1 (P0 set it) and turn == 1 (P0 set it)
flag[0] == 1 && turn == 1— both are true for P1's view!- So P1's loop condition is
flag[0] == 1 && turn == 0which is false (turn is 1) - P1 exits loop and enters... wait, this seems to allow both in!
Let me redo this more carefully...
Assume both P0 and P1 are in CS
- P0 is in CS means: when P0 evaluated Line 3, either
flag[1] == 0orturn == 0 - P1 is in CS means: when P1 evaluated Line 3, either
flag[0] == 0orturn == 1
Consider the ordering of Line 2 executions (setting turn):
- P0 executes
turn = 1 - P1 executes
turn = 0 - One happens after the other. The last write determines turn's final value.
Case: P0 writes turn last (turn = 1 is final)
- When P0 evaluates Line 3, turn = 1 (P0 wrote it)
- P0 sees:
flag[1] == 1 && turn == 1 - If flag[1] == 1 (P1 has set it), then the condition is TRUE
- P0 must WAIT. Contradiction: P0 is not in CS.
Case: P1 writes turn last (turn = 0 is final)
- When P1 evaluates Line 3, turn = 0 (P1 wrote it)
- P1 sees:
flag[0] == 1 && turn == 0 - If flag[0] == 1 (P0 has set it), then the condition is TRUE
- P1 must WAIT. Contradiction: P1 is not in CS.
In both cases, the process that writes turn last must wait.
Conclusion: At most one process can be in CS. Mutual exclusion is satisfied. ∎
The key insight in Peterson's correctness is the turn variable. When both processes want in, the last one to write turn = other defers to the other process. Since writes to turn are totally ordered, exactly one process writes last, and that one waits. The "politeness" of giving the other priority is what breaks the tie.
Proof of Progress:
We must show: if CS is empty and at least one process wants in, someone will enter.
Case 1: Only P0 wants in (P1 is in remainder)
- P1 is in remainder, so
flag[1] = 0(from P1's last exit) - P0 executes entry: sets
flag[0] = 1, setsturn = 1 - P0 evaluates:
flag[1] == 0 && turn == 1 - First conjunct is FALSE (flag[1] == 0)
- Loop exits immediately
- P0 enters CS. Progress satisfied.
Case 2: Only P1 wants in (P0 is in remainder)
- Symmetric to Case 1. P1 enters immediately.
Case 3: Both P0 and P1 want in
- Both set their flags and turn
- One of them writes turn last (see mutex proof)
- The one who wrote turn last waits (while-loop)
- The other one's loop condition becomes false, and it enters
- Someone enters. Progress satisfied.
Conclusion: In all cases, someone enters CS when CS is empty and there's demand. Progress is satisfied. ∎
Proof of Bounded Waiting:
We show that if P0 is waiting, P1 can enter at most once before P0 does.
Suppose P0 is waiting in Line 3, meaning: flag[1] == 1 && turn == 1
P1 is in CS and finishes:
- P1 executes exit:
flag[1] = 0 - P0's while-loop:
flag[1] == 0 && turn == 1— first conjunct is now FALSE - P0 exits loop and enters CS
But what if P1 immediately wants in again?
- P1 enters entry: sets
flag[1] = 1, setsturn = 0 - P1 evaluates:
flag[0] == 1 && turn == 0- flag[0] is still 1 (P0 is waiting, hasn't exited yet)
- turn is 0 (P1 just set it)
- Both TRUE: P1 must wait!
- Meanwhile, P0:
flag[1] == 1 && turn == 0- flag[1] is 1 (P1 just set it)
- turn is 0 (P1 set it)
- First TRUE, second FALSE: P0 exits loop, enters CS!
Bound: After P0 starts waiting, P1 can enter at most once (if P1 was already about to enter or in CS). After that, P1 must wait for P0.
Conclusion: Bounded waiting is satisfied with bound = 1. ∎
Requirements in Practice
How do real-world synchronization primitives fare against these requirements? Let's evaluate common mechanisms:
| Primitive | Mutex | Progress | Bounded Waiting | Notes |
|---|---|---|---|---|
| Test-and-Set Spinlock | ✅ | ✅ | ❌ | Simple but unfair; starvation possible under contention |
| Ticket Lock | ✅ | ✅ | ✅ | FIFO fair; wait bounded by queue size |
| MCS Lock | ✅ | ✅ | ✅ | Scalable + FIFO fair; good for NUMA |
| Pthread Mutex | ✅ | ✅ | ⚡ | Implementation-dependent; often fair but not guaranteed by POSIX |
| Peterson's Algorithm | ✅ | ✅ | ✅ | Two-process only; proven correct |
| Bakery Algorithm | ✅ | ✅ | ✅ | N-process software solution; proven correct |
| Semaphore (general) | ✅ | ✅ | ⚡ | Depends on implementation; may or may not be fair |
| Monitor (Java synchronized) | ✅ | ✅ | ❌* | No fairness guarantee; *ReentrantLock can be fair |
| Read-Write Lock | ✅ | ✅ | ⚡ | Often has preferences (readers or writers) |
Key Observations:
-
Mutual exclusion is universally satisfied—any primitive that doesn't satisfy it is broken and unusable.
-
Progress is usually satisfied, as long as the algorithm doesn't have structural deadlock issues.
-
Bounded waiting is the differentiator—fair vs. unfair locks, scalable vs. unscalable. This is often where performance and fairness trade off.
Choosing Based on Requirements:
- Low contention: Simple spinlock is fine (starvation unlikely)
- High contention: Need fair lock (ticket, MCS) to prevent starvation
- Real-time systems: Bounded waiting is mandatory; fairness is required for predictable timing
- General servers: Consider load patterns; fairness matters when many clients wait concurrently
Many developers assume their mutex is fair because it seems to work. But under high contention, unfair locks can cause severe latency outliers—most requests complete quickly, but some wait 1000x longer. Always check the documentation and test under stress. If fairness is required, use an explicitly fair primitive.
Beyond the Three Requirements
The three classical requirements—mutual exclusion, progress, and bounded waiting—are the foundation. But modern systems often require additional properties:
Additional Properties for Production Systems:
- •Scalability — Performance should not degrade catastrophically as the number of contending threads increases. Simple spinlocks fail here due to cache coherence traffic.
- •Locality — Lock algorithms should minimize cross-processor memory traffic. MCS locks keep each waiter spinning on a local variable, avoiding cache-line bouncing.
- •Low Latency (Uncontended) — The fast path (acquiring an uncontested lock) should be extremely fast. Futexes achieve this with userspace-only atomic operations.
- •Robustness — The lock should handle error conditions (process crash, signal delivery, timeout) gracefully. Robust mutexes can detect holder death.
- •Priority Respect — Higher-priority threads should not be indefinitely blocked by lower-priority threads. Priority inheritance and priority ceiling protocols address this.
- •Composability — Multiple locks should compose correctly without deadlock. This requires lock ordering disciplines or other deadlock avoidance techniques.
The Trade-Off Space:
No single lock design excels at everything. The design space involves trade-offs:
| Property | Simple TAS | Ticket Lock | MCS Lock | Mutex + Futex |
|---|---|---|---|---|
| Implementation complexity | Very Low | Low | Medium | High |
| Uncontended latency | Very Low | Low | Medium | Low |
| Scalability under contention | Very Poor | Poor | Excellent | Good |
| Fairness | None | FIFO | FIFO | Implementation-dependent |
| Memory per lock | 1 word | 2 words | 2 words + queue | Variable |
| Kernel involvement | None | None | None | On contention |
Choosing the right synchronization primitive requires understanding your specific needs. Questions to ask: How many threads will contend? How long is the critical section? Is fairness required? Is real-time response needed? Is kernel mode acceptable? The answers guide the choice.
KEEP LEARNING
There’s more to understand.
You’re reading the introduction. Create a free account to read the complete lesson, save notes and keep track of your progress.