Hello! Welcome to our next lesson on C programming and threads.
In our last session, we implemented a lock-free counter using C11's _Atomic types. We saw how this provides a high-performance alternative to mutexes by leveraging indivisible hardware instructions. However, we relied on the compiler and CPU to handle a simple counter++ operation correctly. Today, we'll peel back that layer of abstraction and investigate a subtle but critical aspect of modern computer architecture: instruction reordering.
Your learning outcome for this lesson is to describe how compiler and processor instruction reordering can cause concurrency bugs. Understanding this is fundamental to writing correct and robust lock-free code, and it provides the essential "why" behind the memory barriers we will explore in our next lesson.
The "As-If" Rule and Its Limits
Both compilers and processors are constantly looking for ways to make your code run faster. One of the most common techniques is to reorder instructions. This is governed by a principle often called the "as-if" rule: the final result of any single-threaded program must be the same as if the instructions were executed exactly in the order you wrote them. For single-threaded code, this optimization is completely invisible and safe.
However, this rule makes no guarantees about what other threads might observe while the code is running. When multiple threads share memory, these performance optimizations can suddenly become visible, leading to baffling concurrency bugs.
Let's break this down into the two main sources of reordering: the compiler and the processor.
1. Compiler Reordering
When you compile your C code with optimizations enabled (e.g., gcc -O2), the compiler takes significant liberties to restructure your program's machine code.
Memory Ordering at Compile Time
To start, let's read a classic article on this topic, 'Memory Ordering at Compile Time' by Preshing on Programming. It clearly explains the motivation and provides a perfect example.
Please read the introduction and the section 'Compiler Instruction Reordering'. Focus on the simple example where A=1; B=0; is reordered by the compiler and how this could cause a problem for the sendMessage example with the IsPublished flag.
As the article explains, the compiler might reorder instructions if they are independent. This transformation from source code to machine code is a key source of unexpected behavior.

A Classic Concurrency Bug Example
Consider this common pattern for passing data from a "producer" thread to a "consumer" thread:
// Shared global variables
int value = 0;
int is_ready = 0;
// Producer Thread
void producer() {
value = 42; // 1. Prepare data
is_ready = 1; // 2. Signal that data is ready
}
// Consumer Thread
void consumer() {
if (is_ready) { // 3. Check if data is ready
// 4. Use the data
printf("The value is %d\n", value);
}
}
Logically, this seems correct. The consumer only reads value after is_ready is set. However, since the writes to value and is_ready are independent from the compiler's single-threaded perspective, it is free to reorder them in the producer function:
// Potentially reordered machine code for the producer
is_ready = 1;
value = 42;
If the producer thread is interrupted after setting is_ready but before setting value, the consumer thread could wake up, see is_ready == 1, and print "The value is 0". This is a classic data race caused by compiler reordering.
The following video, though focused on C++, gives an excellent overview of the kinds of optimizations compilers perform and demonstrates this exact bug. The principles are identical in C.
C++ Memory Model: from C++11 to C++23 - Alex Dathskovsky - CppCon 2023
Let's watch a segment of 'C++ Memory Model' from CppCon 2023. It provides great examples of compiler optimizations and shows how they can break a simple lock.
Please watch two segments: 02:52 to 08:18: This section showcases various compiler optimizations like pulling expressions from loops and loop unrolling. This will give you a concrete sense of how aggressively compilers can transform code. 17:37 to 18:55: This part shows a classic concurrency bug using flags, very similar to our is_ready example. Observe how reordering can cause two threads to enter a critical section simultaneously.
A Quick Word on volatile
You might think the volatile keyword could solve this. It's a common misconception. volatile tells the compiler not to optimize away reads or writes to a variable and prevents the compiler from reordering volatile accesses relative to each other. However, it does not prevent processor reordering. Therefore, volatile is not a synchronization tool for multithreaded programming on modern systems.
The CppCon video makes this point very clearly.
C++ Memory Model: from C++11 to C++23 - Alex Dathskovsky - CppCon 2023
Let's debunk a common myth. This section of the same CppCon video explains why volatile is not the tool for multithreaded synchronization.
Watch from 22:09 to 24:50. The key takeaway is: 'volatiles are not synchronization tools... The CPU can do whatever it wants with it.'
2. Processor Reordering
Even if the compiler generates machine code in the exact order you intended, the CPU itself can execute those instructions out of order. This is a hardware optimization to hide memory latency and keep the processor's execution units busy.
A key mechanism behind this is the store buffer. When a CPU core writes a value, it doesn't go straight to main memory. It's often placed in a small, fast, private buffer on that core. The core can then continue executing other instructions while the write drains from the store buffer to the globally visible L1/L2/L3 caches and main memory.
This has a profound implication: a write on one core may not be immediately visible to other cores.
Advanced Topics: Hardware Memory Barriers
This short video, 'Advanced Topics: Hardware Memory Barriers,' provides an excellent, intuitive explanation of the store buffer and how it leads to reordering.
Watch from the beginning to 06:57. The video introduces the store buffer as an optimization and explains the crucial rule on x86 processors: 'Reads may be reordered with older writes to different locations.' This sets the stage for the classic bug.
The Litmus Test for Processor Reordering
The consequences of processor reordering can be demonstrated with a simple "litmus test." Imagine two threads running on two different cores, with two shared variables x and y, both initially 0.
- Thread 1:
x = 1; r1 = y; - Thread 2:
y = 1; r2 = x;
What are the possible final values for the local registers r1 and r2?
- If Thread 1 runs completely before Thread 2:
xbecomes 1,r1becomes 0. Thenybecomes 1,r2becomes 1. Result:(r1=0, r2=1). - If Thread 2 runs completely before Thread 1:
ybecomes 1,r2becomes 0. Thenxbecomes 1,r1becomes 1. Result:(r1=1, r2=0). - If the operations are interleaved:
x=1, theny=1, thenr1=y, thenr2=x. Result:(r1=1, r2=1).
Is it possible to get the result r1 = 0 and r2 = 0? Sequentially, it seems impossible. If r1 is 0, then Thread 1 must have read y before Thread 2 wrote to it. If r2 is 0, then Thread 2 must have read x before Thread 1 wrote to it. This implies a contradictory ordering.
However, due to processor reordering, the result (r1=0, r2=0) is entirely possible on modern CPUs!
Here's how:
- Core 1 (Thread 1): Executes
x = 1. The value1is put into Core 1's private store buffer. It is not yet visible to Core 2. - Core 2 (Thread 2): Executes
y = 1. The value1is put into Core 2's private store buffer. It is not yet visible to Core 1. - Core 1 (Thread 1): Executes
r1 = y. It readsyfrom main memory, which is still0. So,r1becomes0. - Core 2 (Thread 2): Executes
r2 = x. It readsxfrom main memory, which is still0. So,r2becomes0.
This reordering of a store followed by a load to a different address (StoreLoad reordering) is permitted on many architectures, including the ubiquitous x86/x64, for performance reasons.
The video you just started explains and demonstrates this exact test.
Advanced Topics: Hardware Memory Barriers
Let's continue with the video, which will now walk through this litmus test and show it happening in practice.
Watch from 06:57 to 13:57. This section explains the litmus test in detail and then runs a C++ program that proves the surprising (r1=0, r2=0) result actually occurs. The principles are directly applicable to C.
Test your understanding!
You have the following code running in two separate threads, with value and is_ready initially zero:
Thread A:
value = 100;
is_ready = 1;
Thread B:
while (!is_ready) {
// spin
}
printf("%d\n", value);
Another developer argues, "This code is unsafe. The compiler could reorder the two lines in Thread A, causing Thread B to print 0."
You investigate and find that your compiler, for this specific code and optimization level, did not reorder the instructions in the final assembly. Does this mean the code is now safe? Why or why not?
Show answer
No, the code is still not safe.
Even if the compiler generates the machine instructions in the correct order (store value, then store is_ready), the processor can still reorder their execution or visibility.
Specifically, the processor running Thread A could place the write to value in its store buffer while making the write to is_ready globally visible first. If Thread B, running on another core, sees is_ready == 1 and immediately reads value, it might read the old value of 0 because the write from Thread A's store buffer has not yet been committed to the shared cache. This is a classic StoreStore reordering scenario from the perspective of an observing thread, enabled by mechanisms like the store buffer.
Conclusion
You've now seen the two powerful but hidden forces that can break concurrent code. While they are essential optimizations that make modern computers fast, they violate our intuitive, sequential understanding of how a program runs.
Key Takeaways:
- Two Sources: Instruction reordering happens at both compile time (by the compiler) and run time (by the processor).
- The "As-If" Rule: These reorderings are permitted as long as they don't change the observable behavior of a single-threaded program. This guarantee does not extend to multithreaded programs.
- Compiler Reordering: The compiler can re-sequence independent instructions to optimize the final machine code.
- Processor Reordering: The CPU uses features like out-of-order execution and store buffers to hide memory latency, which can result in memory operations becoming visible to other cores in an order different from the program order.
volatileis not the answer: It only constrains the compiler, not the CPU, making it insufficient for thread synchronization.
Preview of the Next Lesson:
So, how do we write correct lock-free code in the face of these reorderings? We need a way to issue explicit commands to both the compiler and the CPU, telling them, "Do not reorder memory operations across this point." These commands are called memory barriers or fences, and in our next lesson, we will learn how to use them to enforce the memory ordering our algorithms require.