Skip to main content

Welcome to a Grasp lesson

Create a free account to keep studying and create your own study path.

Lesson 25

Understanding Happens-Before Relationships

Recommended resources

Hello! Welcome back.

In our last lesson, you learned how to perform atomic read-modify-write operations using std::atomic and compare-and-swap loops. You now have the tools to make operations indivisible. However, atomicity is only half of the story in concurrent programming. The other, equally crucial half is memory ordering: ensuring that the results of an operation in one thread become visible to other threads in a predictable way.

This lesson addresses a fundamental concept that underpins all of C++ concurrency. Your learning outcome is to explain the 'happens-before' relationship in concurrent programming. Understanding this formal relationship is the key to reasoning about the correctness of lock-free code and avoiding subtle, devastating bugs.

1. The Root of the Problem: Why Order Matters

In a single-threaded program, you can generally assume that code executes in the order it's written. This is not true in a multi-threaded world. To maximize performance, both the compiler and the CPU are allowed to reorder instructions, as long as it doesn't change the observable outcome of a single thread. This is known as the "as-if" rule.

When multiple threads are involved, this reordering can cause chaos. A write to a variable in one thread might not become visible to another thread when you expect it to.

To understand why a formal memory model is necessary, watch the first few minutes of the following talk. It provides the essential context about the layers of reordering that can happen between your source code and the final execution.

The C++ Memory Model - Valentin Ziegler @ Meeting C++ 2014

The talk 'The C++ Memory Model' by Valentin Ziegler introduces why we can't trust the source code order in a concurrent context. It highlights optimizations by the compiler, CPU, and cache system that can reorder memory operations.

Watch from the beginning (00:25) to 02:59. Focus on the core idea that what you write is not what the machine executes directly, and these differences become critical with multiple threads.

This brings us to a critical question: if we can't rely on the source code order, how can we ever write correct concurrent programs? The answer is that the C++ standard provides a set of guarantees, and the most important of these is the happens-before relationship.


2. Defining the "Happens-Before" Relationship

"Happens-before" is a formal guarantee provided by the C++ standard. It precisely defines when the effects of one operation are guaranteed to be visible to another. It's not about wall-clock time; it's about a causal relationship that prevents undesirable reordering.

A "happens-before" relationship is formally built from two simpler relations:

  1. Sequenced-before: Describes the ordering of operations within the same thread.
  2. Synchronizes-with: Describes a synchronization point between two different threads.

The full "happens-before" relationship is then the transitive closure of these two. In simple terms: if A happens-before B, and B happens-before C, then A happens-before C. This allows us to chain together ordering guarantees within and across threads.

The following video segments from a CppCon talk provide a concise and standard-compliant definition of these components.

CppCon 2018: Bryce Adelstein Lelbach “The C++ Execution Model”

Bryce Adelstein Lelbach's talk on 'The C++ Execution Model' breaks down the formal relations that govern execution order in C++. We will watch three short clips to define each part of the happens-before relationship.

Please watch the following three segments: 12:50 - 18:28: This explains 'Sequenced-before', the ordering guarantee within a single thread. 23:26 - 26:47: This introduces 'Synchronizes-with', the mechanism for creating order between threads using things like atomics or mutexes. 26:47 - 28:10: This clip puts it all together to define the 'Happens-before' relationship as a combination of the first two.

To summarize the key points from the video:

  • Sequenced-before: If A; B; are in the same thread, A is sequenced-before B. This is the ordering you intuitively expect from reading the code.
  • Synchronizes-with: An operation A (like an atomic store-release) in one thread can synchronize-with an operation B (like an atomic load-acquire) in another thread.
  • Happens-before: This relationship is established if A is sequenced-before B, if A synchronizes-with B, or if there is a chain of these relationships connecting A and B.

If A happens-before B, the memory effects of A are guaranteed to be visible to B.


3. A Critical Distinction: Happens-Before vs. Happening Before

This is one of the most confusing but important points for new C++ concurrency programmers. The formal, hyphenated happens-before relation is not the same as the everyday English phrase "happens before".

Let's clarify this with an excellent article from Preshing on Programming, which is a classic resource on this topic.

The Happens-Before Relation

The article 'The Happens-Before Relation' by Jeff Preshing clearly explains the formal relationship and, crucially, distinguishes it from the simple temporal order of events.

Read the article from the beginning up to (but not including) the final summary section ('This post hasn’t really shown anything new...'). Pay close attention to the two key points and the code examples that demonstrate them: Happens-Before Does Not Imply Happening Before: Why a formal guarantee doesn't dictate the exact execution order. Happening Before Does Not Imply Happens-Before: Why observing events in a certain order at runtime doesn't give you a formal guarantee (and can lead to data races).

This distinction is the reason we need explicit synchronization. Just because thread B saw a value written by thread A doesn't mean a happens-before relationship exists. Without that formal relationship, the compiler and CPU are free to reorder other memory operations, and your program will contain a data race, which leads to undefined behavior.


4. Data Races and Happens-Before

The C++ standard has a very precise definition of a data race, which hinges on the happens-before relationship:

A data race occurs when there are two conflicting memory accesses (at least one of which is a write) in different threads, and they are not ordered by a happens-before relationship.

Let's see this in a concrete code example. The following article provides a perfect illustration of a data race caused by a lack of a happens-before relationship and shows how to fix it.

C++ Memory Model: Migrating from x86 to ARM - ArangoDB

The ArangoDB blog post 'C++ Memory Model' has an excellent, practical example that demonstrates a data race with relaxed atomics and how to fix it by establishing a happens-before relationship.

Please read the section titled 'Example'. Focus on: Why the first code block, using memory_order_relaxed, contains a data race. (Hint: there is no happens-before link between Thread A initializing n->x and Thread B reading it). How the second code block, using memory_order_release and memory_order_acquire, fixes the race by creating a synchronizes-with (and therefore happens-before) relationship.

The key insight is that the store with memory_order_release in Thread A synchronizes-with the load with memory_order_acquire in Thread B. This creates a happens-before edge. Because the initialization n->x = 42 is sequenced-before the release-store, and the release-store happens-before (via synchronization) the acquire-load, and the acquire-load is sequenced-before the assert, we get a complete chain.

n->x = 42 happens-before assert(n->x == 42)

This guarantees that the write to x is visible before the assert is checked.

We can visualize this chain of events.

Happens-Before Graph for Concurrent Memory Operations
A happens-before graph illustrating the different ordering relationships. An operation `A` can be 'sequenced before' (`sb`) another operation in the same thread. A release operation in one thread can 'synchronize with' (`sw`) an acquire operation in another. This combination creates a transitive 'happens-before' ordering across threads, ensuring memory visibility.

Conclusion

You've just tackled one of the most fundamental and challenging concepts in modern C++. A solid grasp of the happens-before relationship is not academic; it's a practical necessity for writing correct, high-performance concurrent code for your telemetry engine.

Key Takeaways:

  • Compilers and CPUs can reorder memory operations, so you cannot rely on source code order in a multithreaded context.
  • The happens-before relationship is the C++ standard's formal guarantee that a memory write in one thread is visible to another.
  • It is built from sequenced-before (intra-thread ordering) and synchronizes-with (inter-thread ordering).
  • A "data race" is formally defined as conflicting accesses that are not ordered by a happens-before relationship. All data races lead to undefined behavior.
  • Using synchronization mechanisms like atomic acquire/release operations is necessary to establish a happens-before relationship between threads and prevent data races.

What's Next?

You saw in the example that memory_order_relaxed did not create a happens-before relationship, while memory_order_acquire and memory_order_release did. These memory_order enumerations are the knobs you can turn to tell the compiler exactly what kind of ordering guarantees you need.

In the next lesson, we will dive into the different memory orders, starting with the weakest one: memory_order_relaxed. You will learn when it's safe (and unsafe) to use it and how it allows you to trade safety for maximum performance in very specific scenarios.