I once spent an entire afternoon chasing a bug where a shared accumulator variable in a parallel loop produced a different (wrong) total every single run. The loop itself was correct; the problem was that multiple threads were incrementing the same variable at the same time without any protection. That’s the textbook definition of a race condition, and it’s exactly the problem OpenMP’s critical section and mutual exclusion constructs exist to solve.
What Mutual Exclusion Means
Mutual exclusion is the guarantee that only one thread at a time can execute a particular block of code, or access a particular piece of shared data. Without it, when multiple threads perform a read-modify-write sequence on shared memory concurrently — like sum += value — the operations can interleave at the machine-instruction level in ways that lose updates. This isn’t a hypothetical concern; on real CPUs (x86 on Windows/Linux, ARM on Android/iOS/Apple Silicon), sum += value compiles into a load, an add, and a store, and if two threads interleave these three steps, one thread’s update can silently overwrite another’s.
The critical Directive
OpenMP’s primary tool for mutual exclusion is the #pragma omp critical directive. It marks a block of code that only one thread may execute at a time; every other thread that reaches the block must wait until the current thread exits it.
double sum = 0.0;
#pragma omp parallel for
for (int i = 0; i < N; i++) {
double val = compute(i);
#pragma omp critical
{
sum += val; // protected — only one thread at a time
}
}
Under the hood, critical is typically implemented using a mutex-like lock managed by the OpenMP runtime (on Linux, this often maps to a futex-based lock in libgomp; on Windows, to a critical section or similar kernel object). When a thread enters the critical block, it acquires this internal lock; when it exits, the lock is released, allowing the next waiting thread to proceed.
Named Critical Sections
By default, all unnamed critical blocks in a program share the same implicit lock — meaning a thread inside one unnamed critical section blocks every other thread trying to enter any unnamed critical section, even a completely unrelated one elsewhere in the code. This is a common source of unnecessary serialization. OpenMP lets you name critical sections to scope the mutual exclusion more precisely:
#pragma omp critical(sum_lock)
{
sum += val;
}
#pragma omp critical(log_lock)
{
write_log(message);
}
Here, sum_lock and log_lock are independent locks. A thread updating sum doesn’t block another thread trying to write a log message, because they’re protected by different named locks. This is a simple but very effective performance optimization once you understand that unnamed critical sections all share one global lock.
atomic: A Lighter-Weight Alternative
For simple operations — a single read-modify-write on a scalar variable — #pragma omp atomic is usually a better choice than critical. It maps directly onto hardware atomic instructions (like LOCK XADD on x86, or LDXR/STXR load-linked/store-conditional on ARM) rather than a general-purpose lock, making it significantly cheaper:
#pragma omp parallel for
for (int i = 0; i < N; i++) {
#pragma omp atomic
counter++;
}
atomic only supports a limited set of simple expressions (increment/decrement, compound assignment like +=, and a few others as of OpenMP 5.x, including atomic capture for read-and-update-together operations). For anything more complex — multiple statements, function calls, or compound logic — you need critical instead.
#pragma omp atomic capture
{
old_value = counter;
counter += delta;
}
Mutex Locks via the Runtime API
Beyond directives, OpenMP also exposes explicit lock objects through its runtime API, giving you finer manual control akin to a traditional mutex:
omp_lock_t my_lock;
omp_init_lock(&my_lock);
#pragma omp parallel
{
omp_set_lock(&my_lock);
shared_resource_access();
omp_unset_lock(&my_lock);
}
omp_destroy_lock(&my_lock);
omp_set_lock() blocks until the lock is available (equivalent to pthread_mutex_lock on Linux/UNIX or EnterCriticalSection/AcquireSRWLockExclusive on Windows), while omp_test_lock() gives you a non-blocking variant that returns immediately with success or failure, useful for avoiding stalls when a thread has other useful work it could do instead of waiting. OpenMP also provides omp_nest_lock_t for reentrant/recursive locks, which allow the same thread to acquire the lock multiple times (useful in recursive functions that might re-enter a locked region on the same thread) without deadlocking itself, tracking an internal acquisition count.
Diagram: Critical Section Serialization
Thread 0: ---[work]---[ENTER critical]---[sum+=v]---[EXIT]-----[work]---->
Thread 1: -----[work]-------[WAIT..............][ENTER]---[sum+=v]--[EXIT]->
Thread 2: --------[work]-----------[WAIT........................][ENTER]-->
Only one thread occupies the critical section at any instant; all others queue and wait, which is exactly the serialization mutual exclusion guarantees — and exactly why critical sections should be kept as small as possible.
Performance: Why Critical Section Size Matters
Every thread that has to wait at a critical block is doing zero useful work during that wait. If your critical section is large (say, it includes expensive computation alongside the shared-variable update), you’ve effectively serialized a chunk of your “parallel” program, destroying much of the benefit of parallelizing it in the first place. The fix is almost always the same: move as much computation as possible outside the critical section, and keep only the truly shared-state-touching operations inside it.
// BAD — computation happens inside the lock, serializing unnecessarily
#pragma omp critical
{
double val = expensive_compute(i);
sum += val;
}
// GOOD — computation happens outside, lock only protects the update
double val = expensive_compute(i);
#pragma omp critical
{
sum += val;
}
Real-World Use Cases
- Parallel reduction operations (summing, finding max/min across threads) commonly use
criticaloratomicfor the final combine step, though OpenMP’s built-inreduction()clause is usually a better, lock-free choice when applicable. - Shared logging/statistics collection in multi-threaded server applications on Linux uses named critical sections to avoid interleaved or corrupted log output.
- Graphics and simulation engines on Windows and cross-platform game engines use mutex-style locks (often OpenMP-based in scientific/engineering tools) to protect shared scene graphs updated by multiple worker threads.
Comparison: critical vs atomic vs omp_lock_t
| Feature | critical | atomic | omp_lock_t |
|---|---|---|---|
| Granularity | Block of code | Single expression | Manual, arbitrary scope |
| Performance | Moderate overhead | Lowest overhead (HW instr.) | Similar to critical, more control |
| Flexibility | High | Low (limited expression forms) | Highest (manual acquire/release) |
| Naming/scoping | Yes (named critical) | No | Yes (separate lock variables) |
| Reentrant support | No | N/A | Yes (omp_nest_lock_t) |
Troubleshooting
- Unexpectedly slow “parallel” code — check for large or unnamed critical sections causing hidden serialization; profile with tools like Intel VTune to see time spent waiting on locks.
- Deadlocks with
omp_lock_t— forgetting to callomp_unset_lock()on every code path (including early returns or exceptions) leaves the lock held forever; use RAII-style patterns in C++ where possible. - Wrong results despite using
atomic— rememberatomiconly covers the specific memory location in the expression; if your logic depends on multiple related variables being updated together consistently, you likely needcriticalinstead.
Best Practices
- Prefer
atomicovercriticalfor simple scalar updates — it’s cheaper and just as correct. - Name your critical sections to avoid unnecessary global serialization between unrelated protected blocks.
- Keep critical sections as small as possible — move computation outside, leave only the shared-state mutation inside.
- Use the built-in
reduction()clause instead of manualcritical/atomicfor common reduction patterns; it’s typically implemented lock-free per-thread with a final combine step. - Always pair
omp_set_lock()with a guaranteedomp_unset_lock(), even on error paths.
Summary
OpenMP offers a layered toolkit for mutual exclusion: critical for general-purpose protected blocks (with optional naming to avoid over-serialization), atomic for cheap hardware-backed protection of simple scalar operations, and explicit omp_lock_t/omp_nest_lock_t objects for manual, fine-grained control including reentrant locking. Choosing the right tool — and keeping protected regions as small as possible — is central to writing OpenMP code that’s both correct and genuinely fast.
FAQs
Q: What’s the difference between critical and atomic in OpenMP? critical protects an arbitrary block of code using a general lock; atomic protects a single simple expression using cheaper hardware atomic instructions.
Q: Do all unnamed critical sections share the same lock? Yes — by default, every unnamed critical block in a program is mutually exclusive with every other unnamed one, which can cause unintended serialization.
Q: When should I use omp_lock_t instead of critical? When you need manual control over lock acquisition and release across function boundaries, or need a non-blocking omp_test_lock(), or need reentrant locking.
Q: Is reduction() better than manual critical sections for sums? Generally yes — it’s typically implemented with per-thread private accumulation and a lock-free or minimally-locked final combine, which is usually faster than a per-iteration critical section.
References
- OpenMP Architecture Review Board, OpenMP Application Programming Interface, Version 5.2, openmp.org.
- Chapman, B., Jost, G., Van der Pas, R. Using OpenMP, MIT Press.
- GNU libgomp manual, gcc.gnu.org/onlinedocs/libgomp
- Intel OpenMP Runtime Library documentation, intel.com