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:
- Read the current value of
shared_counter. - Add 1 to the value.
- 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 withexpected. If they match, it replaces the current value withdesiredand returnstrue. If they don't match, it updatesexpectedwith the current value and returnsfalse. This operation might fail spuriously (return false even if the values matched), requiring a loop to ensure success.compare_exchange_strong(expected, desired): Similar tocompare_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_boolstd::atomic_charstd::atomic_scharstd::atomic_ucharstd::atomic_shortstd::atomic_ushortstd::atomic_intstd::atomic_uintstd::atomic_longstd::atomic_ulongstd::atomic_llongstd::atomic_ullongstd::atomic_size_tstd::atomic_ptrdiff_tstd::atomic_intptr_tstd::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>.