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.