C++ Memory Model: Do Mutexes Around Atomics Change Observable Behaviors?
When writing multithreaded code in C++, developers often grapple with the nuances of the memory model. A common question arises when mixing synchronization primitives: If you have an atomic variable, does protecting its modification with a mutex change the program's observable behaviors compared to using relaxed atomic operations alone?
In this article, we will analyze this exact scenario using a concrete code example, explore the formal semantics of the C++ memory model, and answer whether these two approaches are truly equivalent.
The Scenario: Locked vs. Unlocked Atomic Write
Consider the following C++ example where we have an atomic boolean is_associated and a mutex:
#include <atomic>
#include <mutex>
#include <thread>
struct Device {
std::atomic<bool> is_associated{false};
std::mutex mutex;
void set_associated(bool r) {
// std::unique_lock lock{mutex}; // #0: Program A1 (with lock) vs Program A (without lock)
is_associated.store(r, std::memory_order_relaxed); // #1
}
void op_dev() {
std::unique_lock lock{mutex};
auto r = is_associated.load(std::memory_order_relaxed); // #2
do_something(r);
}
void do_something(bool r) {}
};
We spawn three threads:
- Thread 0 (A or A1): Calls
set_associated(true). - Thread 1 (B): Calls
op_dev(). - Thread 2 (C): Calls
op_dev().
Since B and C both acquire the same mutex, there is a strict happens-before relationship between their critical sections. Let's assume B happens-before C.
Question 1: Are Program A and Program A1 Equivalent in Observable Behaviors?
Yes. In this specific program, the set of possible observable values read by B and C is identical whether you use the lock in set_associated (Program A1) or not (Program A).
Analyzing Program A1 (With Lock)
Because all three operations (A1, B, and C) acquire the same mutex, their critical sections are totally ordered. This gives us three possible execution sequences:
- A1 → B → C: Both B and C read
true. Result:(true, true). - B → A1 → C: B reads
false, C readstrue. Result:(false, true). - B → C → A1: Both B and C read
false. Result:(false, false).
Analyzing Program A (Without Lock)
Without the lock in set_associated, the write operation (A) can happen concurrently with B and C. However, because is_associated is a std::atomic, there are no data races (which would cause Undefined Behavior). Instead, we must look at C++ coherence rules:
- Read-Read Coherence: If a load
Yreads a value from a storeX, any subsequent load in the modification order (or synchronized via happens-before, like B → C) cannot read a value that precedesXin the modification order.
Because the modification order of is_associated is simple (it transitions from false to true exactly once), the read-read coherence rule guarantees that if B reads true, C must also read true. Thus, the only possible outcomes for (B, C) are:
(false, false)(false, true)(true, true)
Because the set of observable outcomes is exactly the same, Program A and Program A1 are equivalent in terms of their observable behaviors.
---Question 2: Do They Only Differ in Their Formal Semantics?
Yes, but this difference is critical. While the observable outcomes for this specific atomic variable are identical, their formal semantics—specifically the establishment of happens-before relationships—differ significantly. This difference becomes obvious if we introduce non-atomic variables.
Consider this modified struct:
struct DeviceWithData {
int non_atomic_data = 0;
std::atomic<bool> is_associated{false};
std::mutex mutex;
void set_associated(bool r) {
non_atomic_data = 42; // Non-atomic write
// std::unique_lock lock{mutex}; // #0
is_associated.store(r, std::memory_order_relaxed);
}
};
- With the Lock (A1): The lock acquisition and release establish a synchronizes-with relationship. This guarantees that the write to
non_atomic_datahappens-before any read ofnon_atomic_datainop_dev(). The code is safe and correct. - Without the Lock (A): Even if
op_dev()observesis_associated == true, there is no happens-before relationship established between Thread 0 and the other threads because we usedmemory_order_relaxed. Readingnon_atomic_datainop_dev()would result in a data race, which is Undefined Behavior.
Summary: Without the lock, observing a modification of an atomic variable does not allow you to safely infer a happens-before relationship for non-atomic operations surrounding it.
---Question 3: Can "Happens-Before" Violate Physical Time Chronology?
To highlight the concept, the original poster asked: In a conforming implementation, could an execution where A1 happens-before B happens-before C occur chronologically as C on Monday, B on Tuesday, and A1 on Wednesday?
Physically, no. Formally, it's a matter of causality.
The C++ abstract machine defines "happens-before" as a strict partial order on evaluation events to determine which values a read is allowed to return. However, any conforming implementation running on physical hardware must respect physical causality:
- Causality: An effect cannot precede its cause in physical time. If
Breads a value written byA1, thenA1must physically execute beforeBon the hardware. You cannot read a value on Tuesday that won't be written until Wednesday. - The "As-If" Rule: A compiler can reorder instructions only if the observable behavior of the program remains the same. Since writing on Wednesday and reading on Tuesday would violate the laws of physics (and thus alter observable reality/I/O), no conforming compiler or CPU can perform such an optimization.
Therefore, while "happens-before" is a mathematical abstraction, it is constrained by physical time and causality in the real world.
---Conclusion
When dealing with atomic variables in C++:
- If you are only modifying a single atomic variable, adding a mutex around its write operation does not alter its observable states due to C++'s built-in atomic coherence rules.
- If your atomic variable acts as a guard for other non-atomic data, you must establish proper synchronization—either by using a mutex or by utilizing stronger memory orders like
std::memory_order_releaseandstd::memory_order_acquire.