Every operating systems course eventually arrives at the same core idea: the critical section. It sounds dramatic, almost like something out of a hospital drama, but the concept is refreshingly simple once you strip away the jargon. I want to break down exactly what a critical section is, why it exists, how operating systems and programmers enforce safe access to it, and where you’ll actually encounter this concept in real software.
What Is a Critical Section?
A critical section is a segment of code where a process or thread accesses shared resources — variables, files, hardware devices, data structures — that must not be concurrently accessed by more than one process or thread at a time. If two or more execution units enter their critical sections simultaneously and both touch the same shared resource, you get inconsistent results, corrupted data, or crashes. This is the exact mechanism behind race conditions, which I cover in more depth elsewhere, but the critical section is the location where the danger lives, not the bug itself.
Think of a critical section like a single-occupancy restroom on an airplane. Many passengers (processes) need to use it, but only one can be inside at a time. The lock on the door is the synchronization mechanism; the restroom itself is the critical section.
The Structure of a Process Using a Critical Section
Classic operating systems textbooks describe a process’s interaction with a critical section using four distinct regions:
+-------------------+
| Entry Section | <- request permission to enter
+-------------------+
| Critical Section | <- access shared resource
+-------------------+
| Exit Section | <- release permission
+-------------------+
| Remainder Section | <- everything else, no shared access
+-------------------+
The entry section is where a process asks for permission to enter — this is where locks are acquired, semaphores are waited on, or tickets are taken. The critical section itself is the actual work involving the shared resource. The exit section releases whatever lock or permission was acquired, signaling that another process may now enter. The remainder section is just the rest of the program, which doesn’t touch the shared resource and therefore needs no protection.
The Three Requirements a Correct Solution Must Satisfy
Any correct solution to the critical section problem has to guarantee three properties. These come directly from Edsger Dijkstra’s foundational work on concurrent programming and are still the benchmark used to evaluate synchronization mechanisms today.
1. Mutual Exclusion No two processes may be executing in their critical sections at the same time. This is the absolute baseline requirement — without it, you don’t have a solution at all.
2. Progress If no process is currently in its critical section, and some processes want to enter, only those processes not in their remainder section can participate in deciding who goes next, and that decision cannot be postponed indefinitely. In plain terms: the system can’t get stuck in a state where nobody is in the critical section but nobody is allowed to enter either.
3. Bounded Waiting There must be a limit on the number of times other processes are allowed to enter their critical sections after a process has requested entry and before that request is granted. This prevents starvation, where a process could wait forever while other processes keep cutting in line.
How Critical Sections Are Enforced
There are a few broad categories of solutions, ranging from purely software-based approaches to hardware-assisted instructions.
Software-Only Solutions
Early solutions like Dekker’s Algorithm and Peterson’s Algorithm solved the two-process critical section problem using only shared variables — no special hardware instructions required. Peterson’s Algorithm, for instance, uses a turn variable and a flag array to let two processes politely take turns, and it satisfies all three properties above for the two-process case. These algorithms are elegant but don’t scale well to many processes and depend on assumptions about memory ordering that don’t always hold on modern multi-core CPUs with aggressive instruction reordering, which is why they’re mostly of historical and educational interest now.
Hardware-Based Solutions
Modern systems rely on atomic hardware instructions:
- Test-and-Set — an instruction that atomically reads a value and sets it to true, returning the original value, all as one indivisible operation.
- Compare-and-Swap (CAS) — atomically compares a memory location to an expected value, and if they match, swaps in a new value. This is the foundation of most modern lock-free and lock-based concurrency primitives.
These instructions are provided directly by the CPU, so the hardware itself guarantees atomicity — no interrupt or context switch can happen mid-instruction.
OS and Language-Level Constructs Built on Top
On top of these hardware primitives, operating systems and languages offer higher-level tools:
- Mutex locks — the simplest abstraction: lock before entering, unlock after leaving.
- Semaphores — a counter-based mechanism (introduced by Dijkstra) that can protect either a single resource (binary semaphore) or a pool of resources (counting semaphore).
- Monitors — a language-level construct (used heavily in Java via
synchronizedmethods) that automatically manages entry and exit into the critical section along with condition variables for waiting.
Here’s a simple mutex-based critical section in pseudocode:
lock(mutex) // entry section
balance = balance - withdrawal_amount // critical section
unlock(mutex) // exit section
// remainder section continues here
Real Operating System Examples
Linux kernel internals use spinlocks for very short critical sections where the overhead of putting a thread to sleep would exceed the wait time itself, and mutexes for longer critical sections where sleeping is more efficient. The kernel also uses RCU (Read-Copy-Update) for read-heavy data structures, which is a more advanced technique that avoids traditional locking for readers entirely.
Windows exposes the CRITICAL_SECTION object directly in its Win32 API — a lightweight, user-mode synchronization primitive that’s faster than a full kernel mutex because it typically doesn’t require a system call unless there’s actual contention.
Android, running on the Linux kernel, uses the same underlying primitives at the native layer, and Java-level code commonly uses the synchronized keyword, which under the hood locks on an object’s intrinsic monitor — a direct implementation of Dijkstra’s original monitor concept.
iOS developers reach for os_unfair_lock (a lightweight, modern replacement for the older OSSpinLock), NSLock, or dispatch queues via Grand Central Dispatch, which serialize access to a critical section by funneling all work through a single serial queue instead of using an explicit lock at all.
A Practical Example: Bank Account Transfer
Let’s say two threads want to transfer money out of the same bank account concurrently. The balance check and deduction together form the critical section:
Thread 1: Thread 2:
lock(account_mutex)
if balance >= 100:
balance -= 100
unlock(account_mutex)
lock(account_mutex) // blocks until Thread 1 unlocks
if balance >= 100:
balance -= 100
unlock(account_mutex)
Without the lock, both threads could read the same starting balance, both pass the if check, and both deduct — potentially taking the account negative. With the lock in place, Thread 2 is forced to wait until Thread 1 has fully completed its check-and-deduct sequence, preserving correctness.
Common Mistakes When Working With Critical Sections
- Making the critical section too large. Locking more code than necessary increases contention and hurts performance. Only the minimum code that touches shared state should be inside the lock.
- Forgetting to unlock on every code path. If an exception or early return skips the unlock call, you get a permanently locked resource. Language constructs like Python’s
with lock:, C++’s RAII lock guards, or Java’stry/finallyaroundsynchronizedblocks exist specifically to prevent this. - Nesting locks inconsistently. Acquiring locks A then B in one part of the code, and B then A in another, is the single most common cause of deadlocks.
- Assuming a variable is “small enough” not to need protection. Even a simple boolean flag or integer counter needs protection if multiple threads touch it, because most “simple” operations aren’t actually atomic at the machine level.
Critical Sections vs. Race Conditions vs. Deadlocks
It’s worth being precise about terminology, since these three terms get conflated constantly:
- Critical section — the code region that touches shared data.
- Race condition — the bug that happens when the critical section isn’t properly protected.
- Deadlock — a different bug that can happen when protection (locking) is applied incorrectly, causing processes to wait on each other forever.
Best Practices
- Keep critical sections short and simple — do the minimum necessary work while holding a lock.
- Always release locks in a
finally-style block or via RAII patterns so exceptions can’t leave a resource permanently locked. - Avoid calling into unknown or external code while holding a lock, since you can’t be sure that code won’t try to acquire the same lock (reentrancy issues) or take a long time.
- Prefer higher-level, well-tested concurrency primitives over writing your own locking algorithm from scratch.
- Document, next to shared data declarations, exactly which lock protects it.
Nested and Multiple Critical Sections
Real programs rarely have just one critical section protecting one piece of data — they typically have several, each protecting a different resource. This introduces its own set of challenges. When a piece of code needs to enter more than one critical section at once (for example, transferring money between two different bank accounts, each protected by its own lock), the order in which locks are acquired becomes critical to avoiding deadlock.
Consider this scenario, a very common real-world pattern:
Thread 1: lock(accountA); lock(accountB); // transfer A -> B
Thread 2: lock(accountB); lock(accountA); // transfer B -> A
If Thread 1 acquires accountA and Thread 2 simultaneously acquires accountB, each thread is now waiting for a lock the other one holds — a classic deadlock, sometimes called a “deadly embrace.” The standard fix is to establish a consistent global ordering for lock acquisition across the entire codebase — for instance, always locking accounts in order of their account ID, regardless of which direction the transfer is going:
Thread 1: lock(min(accountA, accountB)); lock(max(accountA, accountB));
Thread 2: lock(min(accountB, accountA)); lock(max(accountB, accountA));
Both threads now attempt to acquire locks in the same relative order, eliminating the circular wait condition that causes deadlock.
Lock-Free and Wait-Free Alternatives
For very high-performance systems, traditional locking around a critical section can become a bottleneck under heavy contention, since every thread waiting for the lock is essentially wasted potential. This has driven the development of lock-free and wait-free data structures, which achieve safe concurrent access without traditional mutual exclusion at all, instead relying entirely on atomic hardware instructions like compare-and-swap.
A lock-free queue, for example, might use CAS operations to atomically update a pointer to the next available slot, retrying the operation in a loop if another thread modified the pointer first, rather than blocking. This guarantees that at least one thread makes progress at any given time (the definition of lock-free), even if any individual thread might need to retry several times under contention. Wait-free algorithms go a step further, guaranteeing every thread makes progress within a bounded number of steps regardless of what other threads are doing — a much stronger and harder-to-achieve guarantee.
These techniques are genuinely complex to implement correctly and are generally reserved for performance-critical infrastructure code (database engines, high-frequency trading systems, operating system kernels) rather than everyday application code, where a well-placed mutex around a small critical section is usually more than sufficient and far easier to reason about correctly.
Priority Inversion: A Subtle Critical Section Hazard
A particularly subtle problem that can arise around critical sections in real-time systems is priority inversion — where a high-priority process is forced to wait for a low-priority process to exit a critical section, and that low-priority process is itself delayed by a medium-priority process that has nothing to do with the shared resource at all. This isn’t hypothetical: a famous real-world instance occurred on NASA’s Mars Pathfinder mission in 1997, where priority inversion around a shared data bus caused the system to repeatedly reset itself, until engineers diagnosed the issue and uploaded a fix remotely using a technique called priority inheritance, where a low-priority process holding a critical section is temporarily boosted to the priority of whatever higher-priority process is waiting on it, ensuring it finishes and releases the lock promptly rather than being starved out by unrelated medium-priority work.
Critical Sections in Interrupt Handling
Operating system kernels face an additional wrinkle: critical sections sometimes need protection not just against other processes or threads, but against interrupt handlers, which can run at almost any point, even interrupting the kernel’s own code. On single-processor systems, this is traditionally handled by disabling interrupts entirely for the duration of a very short critical section — an extremely heavyweight technique that’s only acceptable because kernel-level critical sections involving interrupts are meant to be kept extremely brief. On multi-core systems, disabling interrupts on one core doesn’t stop other cores from running, so kernels additionally rely on spinlocks specifically designed for this context, combined with interrupt disabling on the local core, to fully protect data shared between process context and interrupt context.
Summary
A critical section is the portion of a program where shared resources are accessed, and it needs to be protected so that only one process or thread can execute it at a time. Correct solutions to the critical section problem must guarantee mutual exclusion, progress, and bounded waiting. Modern systems achieve this through a layered approach — hardware atomic instructions at the bottom, and mutexes, semaphores, and monitors built on top, exposed through operating system APIs and programming language constructs across Linux, Windows, Android, and iOS alike.
FAQs
Is a critical section the same thing as a lock? No. The critical section is the code that needs protecting; a lock (mutex, semaphore, etc.) is the mechanism used to protect it.
Can a critical section span multiple functions or files? Yes, as long as the same lock is consistently acquired before, and released after, any code path that touches the shared resource, regardless of how that code is organized.
What happens if a process never releases the lock in its exit section? Every other process waiting to enter the critical section will be blocked indefinitely, which violates the progress requirement and effectively deadlocks the system.
Are critical sections only relevant to multi-threaded programs? They’re most commonly discussed in the context of threads and processes, but the same concept applies to any concurrent access — including multiple processes sharing a file, or even interrupt handlers accessing data shared with the main kernel code path.
Why can’t we just disable interrupts to protect a critical section? Disabling interrupts works only in single-processor kernel contexts and is a very heavyweight, low-level technique — it doesn’t work at all for user-space multi-threaded programs and doesn’t scale to multi-core systems, where other cores keep running regardless.
References
- Silberschatz, Galvin, Gagne — Operating System Concepts, “Process Synchronization” chapter
- Dijkstra, E. W. — “Solution of a Problem in Concurrent Programming Control” (1965)
- POSIX.1-2017 — pthread mutex and semaphore specification
- Microsoft Learn — Critical Section Objects documentation
- Linux Kernel Documentation —
Documentation/locking/