Understanding C++ memory_order_relaxed
In C++, std::memory_order_relaxed is the weakest memory
ordering constraint available in the atomic operations library, offering
the highest performance at the expense of synchronization guarantees.
This article explains what memory_order_relaxed is, the
precise guarantees it provides—specifically atomicity and
single-variable modification order—and what it explicitly avoids, such
as cross-thread synchronization and ordering constraints on surrounding
memory accesses.
What Is memory_order_relaxed?
When performing operations on std::atomic objects, C++
requires you to specify a memory order (or it defaults to
std::memory_order_seq_cst). The
memory_order_relaxed model instructs the compiler and
hardware that the atomic operation needs no synchronization with other
memory operations. It treats the operation as an isolated atomic step
without establishing a "happens-before" relationship between
threads.
The Guarantees Provided
Despite being the weakest memory model,
memory_order_relaxed provides critical safety guarantees
that prevent undefined behavior:
1. Atomicity
Every read, write, or read-modify-write operation marked with
memory_order_relaxed is guaranteed to be atomic. This
means:
- Values are never torn or partially written.
- There are no data races on the atomic variable itself.
- Concurrent reads and writes result in valid, fully formed values.
2. Modification Order Consistency (Coherence)
All modifications to a single atomic variable occur in a single, total order called the modification order. Even under relaxed ordering:
- All threads agree on the relative order in which changes to that specific variable occurred.
- Once a thread reads a particular value of an atomic variable, subsequent reads by that same thread will never return an older value (read-read coherence).
- A write to an atomic variable cannot be overwritten by a write that happened earlier in the modification order (write-write coherence).
3. Sequenced-Before Consistency Within the Same Thread
Relaxed operations on the same thread still respect the sequenced-before rules of the language. A thread's own execution remains sequentially consistent with respect to its own program logic.
What It Does Not Guarantee
Understanding relaxed ordering requires knowing what it explicitly leaves out:
- No Synchronization Across Threads: A relaxed store does not synchronize with a relaxed load. It cannot be used to signal another thread that non-atomic data is ready to be read.
- No Ordering Relative to Other Variables: The compiler and the CPU are free to reorder reads and writes around a relaxed atomic operation, provided those reorderings do not violate single-thread dependencies.
For example, using a relaxed atomic variable as a flag:
// Thread 1
data = 42; // Non-atomic store
ready.store(true, std::memory_order_relaxed); // Relaxed atomic store
// Thread 2
if (ready.load(std::memory_order_relaxed)) {
assert(data == 42); // This assertion can fail!
}In this scenario, the CPU or compiler may reorder the assignment of
data and ready, or the changes may become
visible to Thread 2 out of order, leading to a data race on
data.
Typical Use Cases
Relaxed operations are ideal when you need atomic manipulation of a value, but other memory operations do not depend on it:
- Reference Counting Increments: Incrementing a
reference count (such as in
std::shared_ptr) typically only requires atomicity to ensure the count is accurate. Only the decrement requires release-acquire semantics to coordinate object destruction. - Metrics and Diagnostics: Incrementing global counters, tracking statistics, or recording operation frequencies where thread coordination is unnecessary.
- Stop Tokens and Polling: Setting or checking simple independent flags that don't guard surrounding data.