Skip to main content

Welcome to a Grasp lesson

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

Lesson 24

Atomic Read-Modify-Write with `std::atomic`

Recommended resources

Hello! Welcome to the first lesson in our module on low-level concurrency.

In our previous module, we concluded by exploring the observer effect: the principle that measuring a system inevitably perturbs it. You learned that for a high-performance profiler, our primary design goal is to minimize this effect. This means we must avoid slow synchronization mechanisms, like the std::mutex we used before, which can serialize thread execution and completely change the behavior we're trying to observe.

This brings us to the core challenge: how can we modify shared data from multiple threads without using locks? The answer lies in atomic operations. This lesson will introduce you to the fundamental tool for lock-free programming in C++. Your learning outcome is to perform atomic read-modify-write operations using std::atomic. This is the first step toward building the lock-free data structures central to your telemetry engine.

1. The Problem: The Anatomy of an Increment

At first glance, an operation like counter++ seems like a single, indivisible step. However, for the CPU, it's a three-part sequence known as a read-modify-write (RMW) operation:

  1. Read: Load the current value of counter from memory into a CPU register.
  2. Modify: Increment the value in the register.
  3. Write: Store the new value from the register back into memory.

When multiple threads execute this sequence on the same variable concurrently, a "data race" can occur. A thread's operation can be preempted between any of these steps, leading to lost updates and incorrect results.

To see this in action, the following video provides a clear, practical demonstration of this exact problem.

Introduction to C++ Atomic Variables in Multithreaded Programming

The video 'Introduction to C++ Atomic Variables' by Dave Poo sets up a simple multithreaded program that fails for precisely this reason. It provides an excellent visual and conceptual foundation for why we need atomics.

Watch from 03:11 to 10:41. Pay close attention to: How a simple multithreaded sum produces incorrect and inconsistent results. The explanation of the read-modify-write sequence and how it maps to actual assembly instructions.

As you saw, the problem is that there is no guarantee of atomicity. Another thread can modify the shared variable in the tiny window between one thread's read and its subsequent write, causing that thread's update to be lost.


2. The Solution: std::atomic

C++11 introduced the std::atomic template library (<atomic>) to solve this. By wrapping a variable in std::atomic, you instruct the compiler to use special, hardware-assisted instructions that guarantee any operation on that variable is indivisible.

#include <atomic>

// A non-atomic integer, vulnerable to data races
int regular_counter = 0;

// An atomic integer, safe for concurrent access
std::atomic<int> atomic_counter = 0;

When you perform an operation like atomic_counter++, the compiler generates code that ensures the entire read-modify-write sequence completes as a single, uninterruptible transaction. Other threads will see the state of atomic_counter either before the increment or after it, but never an intermediate state.

The following segment from a talk by Fedor Pikus at CppCon explains how std::atomic works at a conceptual level and introduces some of the key operations.

CppCon 2017: Fedor Pikus “C++ atomics, from basic to advanced. What do they really do?”

This clip from 'C++ atomics, from basic to advanced' explains how std::atomic provides a solution and covers the various built-in RMW operations.

Watch from 08:07 to 10:25 and then from 15:04 to 21:12. Focus on: How wrapping a type in std::atomic makes operations like ++ atomic. The different RMW member functions available, such as exchange() and fetch_add(). The special significance of fetch_add(): it atomically modifies the value and returns the value as it was before the modification.

Built-in Read-Modify-Write Operations

As the video discussed, std::atomic provides several convenient RMW member functions for integer and pointer types:

  • fetch_add(): Atomically adds a value and returns the old value.
  • fetch_sub(): Atomically subtracts a value and returns the old value.
  • fetch_and(), fetch_or(), fetch_xor(): Atomically perform bitwise operations and return the old value.
  • exchange(): Atomically replaces the current value with a new one and returns the old value.

The overloaded operators like ++, --, and += are also atomic RMW operations. However, be extremely careful: a seemingly simple expression can hide a data race.

For std::atomic<int> x;:

  • x++ is a single atomic operation.
  • x = x + 1; is not a single atomic operation. This decomposes into an atomic load() from x, followed by an addition, followed by an atomic store() back to x. Another thread can modify x between the load and the store. For this reason, it's safer and clearer to use the member functions (x.fetch_add(1)) or the dedicated operators (x++, x += 1).

3. The Universal Tool: Compare-and-Swap (CAS)

The built-in operations are great, but what if you need to perform a custom RMW operation that isn't provided, like an atomic multiply, max, or a more complex update?

For this, C++ provides a universal building block: compare-and-swap (CAS). The most common form is the compare_exchange_strong() member function.

Its logic is as follows:
"Atomically, compare the current value of the atomic variable with an expected value. If they match, update the variable to a new desired value and report success (true). If they don't match, do nothing to the variable, but update my expected value with the variable's current value and report failure (false)."

Visualizing Compare-and-Swap (CAS) Operation
This diagram from Preshing on Programming shows the logic of a compare-and-swap operation. The `shared` atomic variable is updated to the `desired` value only if its current state matches the `expected` value.

To see how this powerful primitive works, let's study a classic article on the topic.

You Can Do Any Kind of Atomic Read-Modify-Write ...

The article 'You Can Do Any Kind of Atomic Read-Modify-Write Operation' by Jeff Preshing is a fantastic resource that explains how to use compare-and-swap to build any custom atomic operation.

Please read the section 'Compare-and-Swap: The Mother of All RMWs'. Focus on understanding: The signature and behavior of compare_exchange_weak (which is very similar to compare_exchange_strong for our purposes). How the expected parameter is an in-out parameter. The structure of a CAS loop to implement a custom fetch_multiply.


4. Performing a Custom RMW with a CAS Loop

As the article demonstrates, you can perform any RMW operation by placing it inside a CAS loop. This pattern is the foundation of countless lock-free algorithms. Let's break down the fetch_multiply implementation from the article:

// A custom atomic operation to multiply a value and return the old value.
int fetch_multiply(std::atomic<int>& shared, int multiplier) {
    int oldValue = shared.load(); // 1. Load the current value.
    
    // 4. Loop until the CAS succeeds.
    while (!shared.compare_exchange_weak(oldValue, oldValue * multiplier)) {
        // 5. If CAS fails, oldValue is automatically updated by the function
        // with the latest value from 'shared'. The loop then retries.
    }

    // 2. The CAS call attempts the RMW.
    // 3. 'oldValue' is compared with 'shared'. If they match, 'shared' becomes
    //    'oldValue * multiplier'. The loop condition becomes false, and we exit.
    
    return oldValue; // 6. Return the value from the successful attempt.
}

The logic of the loop:

  1. We start by loading the current value of shared into a local variable, oldValue.
  2. We then call compare_exchange_weak. It expects the current value of shared to be oldValue. If it is, it atomically updates shared to oldValue * multiplier and returns true. The while loop terminates.
  3. However, if another thread modified shared between our load() and our compare_exchange_weak() call, the CAS operation will fail.
  4. When it fails, it does two things: it returns false (so the loop continues), and importantly, it updates oldValue with the new value from shared.
  5. The loop immediately retries with this fresh value of oldValue.

This "try, and if you fail, get the new value and try again" loop guarantees that the entire fetch_multiply function behaves as a single, atomic operation, without ever using a lock.

Test your understanding!

Using the CAS loop pattern, implement a function atomic_fetch_max that atomically updates a shared std::atomic<int> to a new value val only if val is greater than the current value. The function should return the value of the atomic variable before the potential update.

int atomic_fetch_max(std::atomic<int>& shared, int val) {
    // Your implementation here
}
Show answer

Here is one possible implementation:

int atomic_fetch_max(std::atomic<int>& shared, int val) {
    int oldValue = shared.load();
    int newValue = (val > oldValue) ? val : oldValue;

    // Keep trying to swap until we succeed.
    // If another thread sets a new maximum, 'oldValue' will be updated by CAS,
    // and our next loop iteration will correctly compare against the new max.
    while (newValue > oldValue && !shared.compare_exchange_weak(oldValue, newValue)) {
        // If the CAS fails, oldValue is now updated with the latest value.
        // We recalculate what the new desired value should be.
        newValue = (val > oldValue) ? val : oldValue;
    }
    
    return oldValue;
}

This implementation continuously tries to set the new maximum. In each iteration, it reads the current value (oldValue), determines the desired newValue (the greater of val and oldValue), and attempts the swap. If another thread wins the race and sets an even higher maximum, our CAS fails, oldValue gets updated, and we try again against the new champion.


Conclusion

You've just taken a significant step into the world of low-level concurrency. Understanding and using atomic RMW operations is essential for writing high-performance, lock-free code.

Key Takeaways:

  • Simple expressions like i++ are actually multi-step read-modify-write (RMW) sequences, which cause data races in multithreaded code.
  • std::atomic guarantees that operations on a variable are indivisible (atomic) by using special hardware instructions.
  • std::atomic provides built-in RMW functions like fetch_add() and exchange().
  • For any custom atomic operation, you can use the compare-and-swap (CAS) loop pattern with compare_exchange_strong or compare_exchange_weak. This is a universal tool for lock-free programming.

What's Next?

So far, we've focused on the atomicity of operations—ensuring they are indivisible. However, there is a second, equally important aspect: memory ordering. How does an atomic operation on one CPU core become visible to another? What guarantees do we have about the order in which different threads see memory writes?

In the next lesson, we will explore the 'happens-before' relationship and the different C++ memory models. This will complete the theoretical picture you need to correctly implement your lock-free ring buffer.