Loading learning content...
At its core, concurrent programming is about one thing: managing access to shared resources. When multiple threads or processes need to access the same memory, file, device, or any other resource, we face a fundamental challenge:
How do we ensure that concurrent accesses don't corrupt the resource or produce incorrect results?
The answer centers on the concept of critical regions (also known as critical sections)—the portions of code that access shared resources and must be executed without interference from other threads.
Every synchronization technique—every mutex, semaphore, condition variable, or lock-free algorithm—ultimately exists to solve the critical region problem. Understanding this concept deeply is essential for understanding all concurrent programming.
By the end of this page, you will understand what critical regions are, the four essential requirements for solving the critical region problem, the structure of entry and exit sections, and how to identify critical regions in code. This foundational knowledge underlies every synchronization mechanism.
A critical region is a section of code that accesses shared resources and must not be executed concurrently by more than one thread.
More formally:
A critical region is a sequence of instructions that accesses a shared resource (memory, file, device, etc.) such that the resource could become inconsistent if the sequence is interrupted mid-execution by another thread accessing the same resource.
The key insight is that critical regions aren't about any code—they're specifically about code that:
Examples of critical regions:
1234567891011121314151617181920212223242526272829303132333435363738
// Example: Identifying the critical region struct Account { int balance; // other fields...}; struct Account accounts[100]; // Shared data // Transfer money between accountsvoid transfer(int from, int to, int amount) { // Non-critical: local validation if (from == to || amount <= 0) return; // =========== CRITICAL REGION START =========== // Multiple shared resources accessed // Must complete atomically if (accounts[from].balance >= amount) { accounts[from].balance -= amount; // Read-modify-write accounts[to].balance += amount; // Read-modify-write } // =========== CRITICAL REGION END ============= // Non-critical: local logging, return value, etc.} // The critical region is the code accessing shared 'accounts' array.// If interrupted between the two modifications:// - Money disappears (taken from 'from', not added to 'to')// - Invariant violated: total money in system changed// - Database auditors will not be happy // Two transfers could also race on the same account:// Transfer A: from=1, to=2, amount=50// Transfer B: from=1, to=3, amount=50// If account 1 has $75, both might "succeed" → overdraftAsk yourself: 'What shared resources does this code access? Could another thread accessing the same resource at the same time cause problems?' If yes, you've found a critical region. The boundaries of the critical region are where access to the shared resource begins and ends.
The critical section problem is the formal statement of the challenge:
Design a protocol that processes/threads can use to coordinate access to their critical sections, ensuring that when any process is executing in its critical section, no other process is executing in its corresponding critical section for the same resource.
Notice the phrase 'corresponding critical section'—processes have separate critical sections for separate resources. Process A in a critical section for resource X doesn't prevent Process B from accessing resource Y.
The general structure of a process with a critical section:
while (true) {
// Entry section: acquire permission to enter
ENTRY_SECTION();
// Critical section: access shared resource
CRITICAL_SECTION();
// Exit section: release permission, signal others
EXIT_SECTION();
// Remainder section: code that doesn't access the shared resource
REMAINDER_SECTION();
}
The entry section and exit section together form the synchronization protocol. The goal is to design these sections such that certain requirements are satisfied.
'Critical region' and 'critical section' are used interchangeably in most literature. Some authors use 'critical region' for higher-level constructs (language-level support) and 'critical section' for the code concept, but this distinction is not universal.
A correct solution to the critical section problem must satisfy three essential requirements:
1. Mutual Exclusion
If a process is executing in its critical section, no other process can be executing in its critical section (for the same resource) at the same time.
This is the primary requirement—the whole point of identifying critical sections. Without mutual exclusion, we have a data race.
2. Progress
If no process is executing in its critical section, and some processes wish to enter their critical sections, then only those processes not in their remainder sections can participate in deciding which will enter next, and this selection cannot be postponed indefinitely.
In simpler terms: if the critical section is free and someone wants it, someone must get it. The system cannot deadlock with everyone waiting for permission when no one holds it.
3. Bounded Waiting
There exists a bound on the number of times other processes can enter their critical sections after a process has made a request to enter and before that request is granted.
In simpler terms: a process cannot be starved forever. If a process requests entry, it must eventually be allowed in—not after an unbounded number of other entries.
| Requirement | What It Prevents | Violation Example |
|---|---|---|
| Mutual Exclusion | Data races, corruption | Two threads modify linked list simultaneously |
| Progress | Deadlock, livelock | All threads wait for permission, none can proceed |
| Bounded Waiting | Starvation | One thread gets the lock every time, another waits forever |
Why all three matter:
Additional desirable properties:
These requirements must be proven, not tested. A solution might appear to work in testing but fail to satisfy bounded waiting under heavy load, or violate mutual exclusion on specific hardware. Formal analysis or proven-correct implementations (like standard library mutexes) are essential.
Understanding why naive approaches fail is illuminating. Let's examine some incorrect solutions:
Attempt 1: Taking Turns
int turn = 0; // Whose turn (0 or 1)
// Process 0
while (turn != 0) ; // Wait
CRITICAL_SECTION();
turn = 1; // Give turn to Process 1
// Process 1
while (turn != 1) ; // Wait
CRITICAL_SECTION();
turn = 0; // Give turn to Process 0
Problem: Fails the progress requirement. If Process 1 finishes its critical section and sets turn = 0, but then enters a long remainder section, Process 0 cannot enter the critical section a second time (turn is 0, letting it enter once, but after that it sets turn = 1 and waits for Process 1). If Process 1 isn't ready to enter yet, Process 0 is blocked even though the resource is free.
Verdict: Mutual exclusion ✓, Progress ✗, Bounded Waiting ✗
Attempt 2: Flag Array
bool flag[2] = {false, false};
// Process 0
flag[0] = true; // I want to enter
while (flag[1]) ; // Wait if other wants in
CRITICAL_SECTION();
flag[0] = false; // I'm done
// Process 1
flag[1] = true; // I want to enter
while (flag[0]) ; // Wait if other wants in
CRITICAL_SECTION();
flag[1] = false; // I'm done
Problem: Possible deadlock. Both processes could set their flags simultaneously, then both wait forever for the other's flag to clear.
Verdict: Mutual exclusion ✓, Progress ✗ (deadlock), Bounded Waiting ✗
Attempt 3: Flag with Turn
bool flag[2] = {false, false};
int turn;
// Process i
flag[i] = true; // I want to enter
turn = j; // But let other go first
while (flag[j] && turn == j) ; // Wait if other wants in AND it's their turn
CRITICAL_SECTION();
flag[i] = false; // I'm done
This is Peterson's Solution! It works because:
turn variable breaks the tieturn last waits (is courteous to the other)Peterson's solution combines 'I want to enter' (flag) with 'but I'll defer to you' (turn). The turn variable specifically resolves the scenario where both processes set their flags simultaneously. Whoever writes to turn last loses (waits), breaking the symmetry that causes deadlock.
Entry and exit sections can be implemented in various ways, each with different trade-offs:
Entry Section Strategies:
1. Busy Waiting (Spinning)
Repeatedly check a condition until it's true:
while (locked) ; // spin
locked = true; // enter
2. Blocking (Sleep/Wake)
Suspend the thread until the lock becomes available:
if (locked) {
add_to_wait_queue();
sleep(); // OS deschedules this thread
}
locked = true;
3. Hybrid (Spin-then-Block)
Spin briefly, then block if lock isn't acquired:
for (int i = 0; i < SPIN_LIMIT; i++) {
if (!locked) { locked = true; return; }
}
add_to_wait_queue();
sleep();
Exit Section Requirements:
The exit section must:
Release the lock atomically — Ensure the lock is fully released before any waiting thread can acquire it
Signal waiters (if blocking) — Wake up one or more waiting threads. Policies include:
Provide memory ordering — Ensure all writes in the critical section are visible before the lock is released (release semantics)
Be interrupt-safe (in kernels) — On single-core systems, might need to restore interrupt state
123456789101112131415161718192021222324
// Complete critical section with entry and exit pthread_mutex_t lock = PTHREAD_MUTEX_INITIALIZER; void process() { // Remainder section: work that doesn't need the lock do_independent_work(); // ===== ENTRY SECTION ===== pthread_mutex_lock(&lock); // Acquire lock; may block // At this point, we have exclusive access // ===== CRITICAL SECTION ===== access_shared_resource(); modify_shared_state(); // Keep this as SHORT as possible // ===== EXIT SECTION ===== pthread_mutex_unlock(&lock); // Release lock; wake waiter // At this point, another thread may have the lock // Remainder section: more independent work do_more_independent_work();}Failing to execute the exit section (e.g., due to early return or exception) leaves the lock held forever, deadlocking all other threads. Use RAII (C++ lock guards), defer (Go), or try/finally (Java) to ensure the exit section always executes.
The scope of a critical region—how much code it contains—significantly impacts both correctness and performance. This is a fundamental design decision.
Coarse-grained locking:
Use one lock for an entire data structure or subsystem.
pthread_mutex_t db_lock; // One lock for entire database
void query(int id) {
lock(&db_lock);
// ALL database operations serialized
result = search_index(id);
data = fetch_record(result);
unlock(&db_lock);
return data;
}
Fine-grained locking:
Use separate locks for different parts of a data structure.
// Each bucket has its own lock
struct HashTable {
struct Bucket buckets[1024];
pthread_mutex_t bucket_locks[1024];
};
void insert(struct HashTable* ht, int key, void* value) {
int b = hash(key) % 1024;
lock(&ht->bucket_locks[b]); // Only lock the relevant bucket
insert_into_bucket(&ht->buckets[b], key, value);
unlock(&ht->bucket_locks[b]);
}
| Aspect | Coarse-grained | Fine-grained |
|---|---|---|
| Parallelism | Low (serialized) | High (concurrent access) |
| Complexity | Simple | Complex |
| Deadlock risk | Lower | Higher |
| Memory overhead | Low (few locks) | High (many locks) |
| Contention | High under load | Low (distributed) |
| Best for | Simple cases, correctness first | High-throughput, scalable systems |
The Golden Rule: Hold Locks for the Shortest Time Possible
Regardless of granularity, critical sections should be as short as possible:
// WRONG: Long critical section
lock(&mutex);
data = compute_expensive_result(); // Slow! Others blocked
shared_result = data;
unlock(&mutex);
// RIGHT: Short critical section
data = compute_expensive_result(); // Do this outside lock
lock(&mutex);
shared_result = data; // Only the shared access is protected
unlock(&mutex);
Move all computation that doesn't require the shared resource outside the critical section. Only the actual access to shared data needs protection.
Begin with coarse-grained locking to get correct behavior. Profile to identify contention bottlenecks. Only then introduce fine-grained locking where profiling shows it's needed. Premature optimization with fine-grained locking often introduces bugs without measurable benefit.
When a thread in one critical region needs to enter another critical region, we have nested critical regions. This is common but introduces significant complexity.
The Deadlock Danger:
Consider two locks A and B, and two threads:
Thread 1: lock(A) → lock(B) → unlock(B) → unlock(A)
Thread 2: lock(B) → lock(A) → unlock(A) → unlock(B)
Possible interleaving:
Deadlock! Neither can proceed.
Solution 1: Lock Ordering
Always acquire locks in the same global order:
// Rule: Always acquire A before B
void thread1() { lock(A); lock(B); /* ... */ unlock(B); unlock(A); }
void thread2() { lock(A); lock(B); /* ... */ unlock(B); unlock(A); }
// Now deadlock is impossible
This is the most common solution. Define a total order on all locks (by address, by hierarchy, by name) and always follow it.
Solution 2: Try-Lock with Backoff
Attempt to acquire the second lock without blocking:
lock(A);
if (!try_lock(B)) {
unlock(A); // Back off
// Wait, retry
}
This avoids deadlock but can cause livelock (repeated backoff without progress).
Solution 3: Lock Hierarchies
Organize locks into levels; only acquire higher-level locks, never lower:
Level 3: Application locks
Level 2: Subsystem locks
Level 1: Data structure locks
Level 0: Base layer locks
// Holding a level 2 lock, you may only acquire level 3, 4, ...
// Never acquire level 1 or 0 while holding level 2
Solution 4: Single Lock
Refactor to eliminate nesting by using one lock that covers both resources. Trades parallelism for simplicity.
// Instead of locking A and B separately
lock(&combined_lock); // Protects both A and B
access_A();
access_B();
unlock(&combined_lock);
Deadlocks in production systems are devastating—they require restart to recover, and they tend to happen at the worst times (under peak load when lock contention is highest). Lock ordering discipline must be designed upfront and enforced through code review. Some systems use runtime deadlock detection, but prevention is better.
Different languages and operating systems provide various abstractions for managing critical regions:
Operating System Primitives:
Language-Level Support:
Java's synchronized:
synchronized(sharedObject) {
// Critical section
// Lock is automatically released when block exits
}
C++ RAII lock guards:
{
std::lock_guard<std::mutex> guard(mutex);
// Critical section
} // Lock released when guard goes out of scope
Rust's Mutex:
let data = mutex.lock().unwrap();
// data is a guard; critical section while it exists
// Lock released when data goes out of scope
// Can't access underlying data without the lock!
| Language/API | Mechanism | Automatic Release | Key Feature |
|---|---|---|---|
| POSIX (C) | pthread_mutex_lock/unlock | No (manual) | Portable standard |
| Windows | EnterCriticalSection/Leave | No (manual) | Fast for in-process |
| Java | synchronized keyword | Yes (block scope) | Built into language |
| C++11 | std::lock_guard | Yes (RAII) | Exception-safe |
| Rust | Mutex<T> | Yes (RAII) | Can't access data without lock |
| Go | sync.Mutex | No (defer helps) | Simple API |
| Python | threading.Lock + with | Yes (context manager) | GIL limits true parallelism |
Rust's Mutex<T> ties the lock to the data it protects. You can't access the data without acquiring the lock—the compiler enforces this. And you can't forget to release the lock—RAII handles it. This eliminates entire classes of bugs at compile time.
Even experienced developers make errors with critical regions. Here are the most common:
1. Forgetting to release the lock:
void process() {
lock(&mutex);
if (error_condition) {
return; // BUG: Lock never released!
}
do_work();
unlock(&mutex);
}
Fix: Use RAII (C++), defer (Go), or try/finally.
2. Holding locks too long:
lock(&mutex);
data = read_from_database(); // Slow! Holds lock while waiting on I/O
result = compute(data); // Slow! Others blocked
update_cache(result);
unlock(&mutex);
Fix: Only hold the lock for the actual shared state access.
3. Wrong lock for the data:
lock(&mutex_A);
shared_data_B++; // BUG: Protected by mutex_B, not mutex_A!
unlock(&mutex_A);
Fix: Document which lock protects which data. Use Rust's type system if possible.
4. Race between check and use (TOCTOU):
if (available) { // Check
lock(&mutex); // Time gap!
use_resource(); // Use - but 'available' may have changed!
unlock(&mutex);
}
Fix: The check must be inside the critical section.
Calling an unknown function (callback, virtual method) while holding a lock is particularly dangerous. That function might try to acquire locks in a different order, leading to deadlock. It might also block indefinitely. This is called 'calling alien code with lock held' and is a major source of deadlocks.
We've examined critical regions—the code sections where concurrent programming challenges arise:
What's next:
We've seen that critical regions must be executed atomically to prevent data races. But what exactly does 'atomic' mean? The final page of this module examines atomicity—the property that ensures operations appear to execute instantaneously, and how hardware and software achieve this essential guarantee.
You now understand critical regions: what they are, the formal requirements for correct solutions, how entry and exit sections work, and the design considerations for scope and nesting. This conceptual framework underlies every synchronization mechanism you'll encounter.