C++ Concurrency: Understanding Atomic Operations

Concurrency in C++ involves managing multiple threads of execution that might access shared data simultaneously. When this happens, a race condition can occur, leading to unpredictable results. Atomic operations provide a mechanism to ensure that certain operations on shared data are performed as a single, indivisible unit, preventing race conditions.

What are Atomic Operations?

An atomic operation is an operation that is guaranteed to execute completely without interruption. In the context of concurrent programming, this means that no other thread can observe the operation in a partially completed state.

Why Use Atomic Operations?

Consider a simple scenario where multiple threads increment a shared counter:

int shared_counter = 0;

// In thread 1:
shared_counter++;

// In thread 2:
shared_counter++;

The increment operation (shared_counter++) is not atomic. It typically involves three steps:

  1. Read the current value of shared_counter.
  2. Add 1 to the value.
  3. Write the new value back to shared_counter.

If thread 1 reads the value, then thread 2 reads the same value before thread 1 writes back, both threads will add 1 to the same initial value, resulting in the counter being incremented only once instead of twice. This is a classic race condition.

Atomic operations prevent this by ensuring that the entire read-modify-write sequence happens as one indivisible step.

C++ Atomic Types

C++ provides atomic types in the <atomic> header. The most common one is std::atomic<T>, where T is the type of the variable you want to make atomic.

Example: Atomic Counter

Using std::atomic, the counter example becomes:

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

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

void increment_counter() {
    for (int i = 0; i < 100000; ++i) {
        atomic_counter++; // This is now an atomic operation
    }
}

int main() {
    std::vector<std::thread> threads;
    for (int i = 0; i < 10; ++i) {
        threads.emplace_back(increment_counter);
    }

    for (auto& t : threads) {
        t.join();
    }

    std::cout << "Final counter value: " << atomic_counter << std::endl;
    // Expected output will be 1000000
    return 0;
}

In this example, atomic_counter++ is guaranteed to be atomic, ensuring that each increment is correctly applied, even when multiple threads call it concurrently. The final result will be the sum of all increments (10 threads * 100000 increments/thread = 1,000,000).

Common Atomic Operations

Atomic types provide member functions for various operations:

  • load(): Atomically retrieves the current value.
  • store(value): Atomically sets the value.
  • exchange(value): Atomically replaces the current value with the given value and returns the old value.
  • compare_exchange_weak(expected, desired): Atomically compares the current value with expected. If they match, it replaces the current value with desired and returns true. If they don't match, it updates expected with the current value and returns false. This operation might fail spuriously (return false even if the values matched), requiring a loop to ensure success.
  • compare_exchange_strong(expected, desired): Similar to compare_exchange_weak, but it is guaranteed not to fail spuriously.
  • Fetch-and-modify operations: e.g., fetch_add(value), fetch_sub(value), fetch_and(value), fetch_or(value), fetch_xor(value). These operations atomically modify the value and return the value before the modification.

Example: Using compare_exchange_weak

compare_exchange_weak is often used in lock-free algorithms. Here's a simplified example demonstrating its usage (though a real-world lock-free stack would be more complex):

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

struct Node {
    int data;
    Node* next;
};

std::atomic<Node*> head(nullptr);

void push_node(Node* new_node) {
    new_node->next = head.load(); // Get current head
    while (!head.compare_exchange_weak(new_node->next, new_node)) {
        // If CAS failed, new_node->next has been updated with the new head.
        // We retry with the updated value.
    }
    // If CAS succeeded, new_node is now the head.
}

int main() {
    Node* node1 = new Node{1, nullptr};
    Node* node2 = new Node{2, nullptr};
    Node* node3 = new Node{3, nullptr};

    std::vector<std::thread> threads;
    threads.emplace_back(push_node, node1);
    threads.emplace_back(push_node, node2);
    threads.emplace_back(push_node, node3);

    for (auto& t : threads) {
        t.join();
    }

    Node* current = head.load();
    while (current) {
        std::cout << current->data << " ";
        Node* temp = current;
        current = current->next;
        delete temp;
    }
    std::cout << std::endl;
    // Output might be 3 2 1 (order depends on thread scheduling)
    return 0;
}

Memory Ordering

Atomic operations also involve memory ordering, which specifies how memory operations in one thread become visible to other threads. The default memory order for most atomic operations is std::memory_order_seq_cst (sequentially consistent), which provides the strongest guarantees but can be the slowest. Other memory orders like std::memory_order_acquire, std::memory_order_release, and std::memory_order_relaxed can offer performance benefits if you understand their implications.

For instance, store with std::memory_order_release ensures that all preceding writes in the current thread become visible to other threads that perform a load with std::memory_order_acquire on the same atomic variable.

Other Atomic Types

Besides std::atomic<T>, C++ provides specialized atomic types for common integral types:

  • std::atomic_bool
  • std::atomic_char
  • std::atomic_schar
  • std::atomic_uchar
  • std::atomic_short
  • std::atomic_ushort
  • std::atomic_int
  • std::atomic_uint
  • std::atomic_long
  • std::atomic_ulong
  • std::atomic_llong
  • std::atomic_ullong
  • std::atomic_size_t
  • std::atomic_ptrdiff_t
  • std::atomic_intptr_t
  • std::atomic_flag: A minimal atomic type that only supports test-and-set operations.

These specialized types are often more efficient than using the generic std::atomic<T>.

Thẻ: C++ Concurrency atomic operations multithreading race condition

Đăng vào ngày 3 tháng 7 lúc 17:32