Skip to Content
Course content

139: Memory Ordering in C11 Atomics

Click on the "Edit" button in the top corner of the screen to edit your slide content.

I've seen this exact bug crash production systems on ARM processors while working perfectly on x86 during development. It's one of the most insidious traps in C11 because the code looks logically sound, but it ignores how modern CPUs actually move data around.

#include <stdatomic.h>
#include <stdbool.h>
#include <stdio.h>
#include <pthread.h>

int shared_data = 0;
atomic_bool ready = false;

void* producer(void* arg) {
    shared_data = 42; // Step 1: Prepare data
    atomic_store_explicit(&ready, true, memory_order_relaxed); // Step 2: Signal ready
    return NULL;
}

void* consumer(void* arg) {
    while (!atomic_load_explicit(&ready, memory_order_relaxed)) {
        // Spin until ready
    }
    printf("Data is: %d\n", shared_data); // Step 3: Read data
    return NULL;
}

The Invisible Reordering Bug

At first glance, the logic is simple: the producer sets the data, then sets the flag. The consumer waits for the flag, then reads the data. If you run this on your Intel laptop, it will likely print "42" every single time. You'll think you're a genius. Then you deploy it to an ARM-based server or a mobile device, and suddenly it prints "0" occasionally.

The problem is memory_order_relaxed. When you tell the compiler and the CPU that an operation is "relaxed," you are giving them permission to reorder instructions as long as the behavior of that single atomic variable remains consistent. The CPU might decide that storing true to ready is faster to do before writing 42 to shared_data. Or, the consumer's CPU might speculatively read shared_data before it even checks if ready is true.

In the eyes of a relaxed memory model, there is no relationship between the atomic flag and the non-atomic data. They are two unrelated events.

Establishing a Happens-Before Relationship

To fix this, we need to create a synchronization point. We don't just need the variable to be atomic; we need to ensure that all memory writes performed by the producer before the flag is set are visible to the consumer after it sees the flag.

This is where memory_order_release and memory_order_acquire come in. Think of them as a pair of handcuffs that lock the ordering of memory operations.

void* producer(void* arg) {
    shared_data = 42; 
    // "Release" ensures all prior writes in this thread are 
    // committed before this store becomes visible to others.
    atomic_store_explicit(&ready, true, memory_order_release); 
    return NULL;
}

void* consumer(void* arg) {
    // "Acquire" ensures that no subsequent reads in this thread 
    // happen before this load completes.
    while (!atomic_load_explicit(&ready, memory_order_acquire)) {
        // Spin
    }
    printf("Data is: %d\n", shared_data); 
    return NULL;
}

By changing the producer to release and the consumer to acquire, we establish a "happens-before" relationship. Everything the producer did before the release store is guaranteed to be visible to the consumer after the acquire load. I generally advise against using memory_order_seq_cst (sequentially consistent) for everything. While it's the safest default, it carries a heavy performance penalty on non-x86 architectures because it forces a global agreement across all cores. Acquire/Release is the "sweet spot" for most producer-consumer patterns.




📋 Practical Task

Implementing a Memory-Fence-Free Data Handover

You are tasked with creating a simple "Message Box" where one thread writes a large struct of data and another thread reads it. You must avoid using mutexes or memory_order_seq_cst to maximize performance.

Requirements:

  • Define a struct Payload containing an array of 100 integers.
  • Use a global Payload instance and an atomic_bool flag named is_published.
  • Implement a publish_data function that fills the payload with values (0-99) and then updates the flag using the correct memory order to ensure the data is visible.
  • Implement a consume_data function that spins until the flag is set using the correct memory order, then verifies that the payload contains the correct values.
  • Run the code in a multi-threaded environment (using pthreads) and ensure there are no data races.
Rating
0 0

There are no comments for now.

to be the first to leave a comment.