Atomic Operation & Memory Model in C++

What is atomic in C++?

In C++, std::atomic is a template class provided by the C++ Standard Library (since C++11) that allows you to create variables that can be safely shared and manipulated across multiple threads without the need for explicit synchronization mechanisms like mutexes.

Key Characteristics of std::atomic:

  1. Thread Safety: Operations on std::atomic variables are guaranteed to be atomic, meaning they complete as a single, indivisible step. This prevents race conditions where multiple threads might try to read, modify, or write to the same variable simultaneously.

  2. Lock-Free Operations: Many atomic operations are implemented without the use of locks, which can lead to better performance and avoid issues like deadlocks.

  3. Memory Ordering: std::atomic provides various memory ordering options (like std::memory_order_relaxed, std::memory_order_acquire, etc.) to control the visibility and ordering of operations across different threads. However, the default behavior (std::memory_order_seq_cst) ensures a strict ordering, which is often sufficient for many use cases.

  4. Specialized Types: C++ provides atomic versions of many fundamental types, such as int, bool, pointers, etc. For more complex types, you might need to use other synchronization mechanisms.

How to Use std::atomic:

Here's a simple example:

#include <atomic>
#include <thread>
#include <iostream>

std::atomic<int> counter(0);

void increment() {
    for(int i = 0; i < 1000; ++i) {
        counter.fetch_add(1, std::memory_order_relaxed);
    }
}

int main() {
    std::thread t1(increment);
    std::thread t2(increment);
    t1.join();
    t2.join();
    std::cout << "Final counter value: " << counter.load() << std::endl;
    return 0;
}

In this example:

  • Two threads increment the same counter variable.

  • Using std::atomic<int> ensures that each fetch_add operation is atomic, preventing race conditions.

  • The final printed value will reliably be 2000, demonstrating thread-safe increments.

What are Atomic Operations in C++?

Atomic operations are operations that are performed as a single, indivisible step. In the context of multithreading, atomic operations ensure that when multiple threads attempt to read or modify a shared variable, these operations do not interfere with each other, maintaining data consistency and preventing undefined behavior.

Common Atomic Operations:

  1. Load and Store: Reading from and writing to an atomic variable.

    int value = atomic_var.load();
    atomic_var.store(10);
  2. Fetch and Add/Subtract: Atomically adding or subtracting a value.

    atomic_var.fetch_add(1);
    atomic_var.fetch_sub(1);
  3. Compare and Exchange: Atomically compares the current value with an expected value and, if they match, replaces it with a new value.

    int expected = 5;
    atomic_var.compare_exchange_strong(expected, 10);
  4. Exchange: Atomically replaces the current value with a new value and returns the old value.

    int old = atomic_var.exchange(20);

These operations ensure that even if multiple threads attempt to perform these operations concurrently, each operation completes fully without being interrupted or causing inconsistent states.

Relating to the ZeroEvenOdd Class

Let's examine how std::atomic and atomic operations are utilized in ZeroEvenOdd class:

class ZeroEvenOdd {
private:
    int n;
    atomic<int> flag = 0;
public:
    ZeroEvenOdd(int n) {
        this->n = n;
    }

    // Other member functions...
};

Purpose of atomic<int> flag

  1. Shared State Management: The flag variable is used to control the execution flow between three different threads: one printing zeros, one printing even numbers, and one printing odd numbers. Since multiple threads access and modify flag, it must be managed safely to prevent race conditions.

  2. Atomicity Ensured: By declaring flag as std::atomic<int>, you ensure that any read or write to flag is atomic. This means that when one thread updates flag, other threads see the change immediately and consistently, without any partial updates or data races.

  3. Synchronization Mechanism: The flag variable acts as a simple synchronization mechanism:

    • zero Function: Waits until flag is 0, prints 0, and then sets flag to 1 (odd) or 2 (even) based on the current iteration.

    • even Function: Waits until flag is 2, prints the even number, and resets flag to 0.

    • odd Function: Waits until flag is 1, prints the odd number, and resets flag to 0.

Why Not Use Non-Atomic Variables?

If flag were a regular int, the following issues could arise:

  • Race Conditions: Multiple threads might try to read and write to flag simultaneously, leading to inconsistent or unexpected behavior.

  • Undefined Behavior: Concurrent modifications without synchronization can cause undefined behavior, making the program unreliable.

By using std::atomic<int>, you avoid these problems because all operations on flag are inherently thread-safe.

Example Flow:

  1. Initial State: flag = 0.

  2. zero Thread:

    • Sees flag == 0, prints 0.

    • Sets flag to 1 (if the next number is odd) or 2 (if even).

  3. odd or even Thread:

    • Depending on flag, either the odd thread or the even thread proceeds.

    • Prints the respective number and resets flag to 0, allowing the zero thread to print the next 0.

This cycle continues until all numbers up to n are printed in the desired order.

Conclusion

  • std::atomic provides a way to perform thread-safe operations on variables without explicit locks.

  • Atomic operations ensure that read-modify-write sequences happen indivisibly, preventing race conditions.

  • In the ZeroEvenOdd class, std::atomic<int> flag is crucial for coordinating between multiple threads to maintain the correct printing sequence.

Last updated