Appearance
Atomic Operations
Atomic operations provide thread-safe access to shared variables without the overhead of mutexes. They're perfect for simple operations like counters and flags.
The Problem with Non-Atomic Operations
Consider this simple increment:
cpp
int counter = 0;
void increment() {
counter++; // This is NOT atomic!
}The machine code for the above function would looks something like this:
cpp
mov eax, [counter] # Loads the variable in a register
add eax, 1 # Adds 1 to the register
mov [counter], eax # Stores the variable backMultiple threads can interleave these instructions! Additionally, because all modern processors make use of caches, the value of counter may not be updated in the main memory immediately. Therefore, an old value of counter may be read by one thread and a new value may be read by another thread.
What happens with multiple threads:
cpp
Thread A: reads counter (0)
Thread B: reads counter (0)
Thread A: increments (1)
Thread B: increments (1)
Thread A: writes counter (1)
Thread B: writes counter (1) // Lost update!Result: Counter ends up as 1 instead of 2.
Its important to note that even if the operations happened in order, the result can be incorrect due to L1/L2/L3 caches.
The Solution: std::atomic
std::atomic makes operations atomic - they happen as a single, indivisible unit. While generating code, C++ emits special atomic instructions that ensure the operation is executed atomically.
Basic Declaration
cpp
#include <atomic>
std::atomic<int> counter{0}; // Initialize to 0
void increment() {
counter++; // This is atomic!
}The atomicity is provided on a hardware level, and makes this much faster than mutexes. The machine code emitted for the above may look something like this:
cpp
lock inc [counter] # Loads, adds 1, and stores in a single instruction!Core Atomic Operations
1. fetch_add() - Atomic Addition
cpp
std::atomic<int> counter{0};
void increment() {
int old_value = counter.fetch_add(1); // Add 1, return old value
// old_value contains the value before increment
}What fetch_add does:
- Atomically adds the specified value
- Returns the old value before the addition
- Thread-safe - no race conditions
2. load() - Atomic Read
cpp
std::atomic<int> counter{0};
int get_value() {
return counter.load(); // Atomic read
}What load does:
- Atomically reads the current value
- Thread-safe - no partial reads
- Returns the current value
3. store() - Atomic Write
cpp
std::atomic<int> counter{0};
void set_value(int new_value) {
counter.store(new_value); // Atomic write
}What store does:
- Atomically writes the new value
- Thread-safe - no partial writes
- Overwrites the current value
Common Atomic Patterns
1. Simple Counter
cpp
std::atomic<int> counter{0};
void increment() {
counter.fetch_add(1);
}
int get_count() {
return counter.load();
}
void reset() {
counter.store(0);
}2. Flag Operations
cpp
std::atomic<bool> ready{false};
void set_ready() {
ready.store(true);
}
bool is_ready() {
return ready.load();
}Performance Benefits
Mutex Approach
cpp
std::mutex mtx;
int counter = 0;
void increment() {
std::lock_guard<std::mutex> lock(mtx);
counter++;
}Atomic Approach
cpp
std::atomic<int> counter{0};
void increment() {
counter.fetch_add(1); // Much faster!
}Why atomics are faster:
- No lock contention: No waiting for locks
- Hardware support: CPU provides atomic instructions
- No context switching: No OS involvement
- Cache-friendly: Better cache performance
Common Atomic Operations
Arithmetic Operations
cpp
std::atomic<int> counter{0};
counter.fetch_add(5); // Add 5
counter.fetch_sub(3); // Subtract 3
counter.fetch_or(0x10); // Bitwise OR
counter.fetch_and(0x0F); // Bitwise AND
counter.fetch_xor(0xFF); // Bitwise XORIncrement/Decrement
cpp
std::atomic<int> counter{0};
counter++; // Equivalent to fetch_add(1)
counter--; // Equivalent to fetch_sub(1)
++counter; // Equivalent to fetch_add(1)
--counter; // Equivalent to fetch_sub(1)Assignment
cpp
std::atomic<int> counter{0};
counter = 42; // Equivalent to store(42)
int value = counter; // Equivalent to load()Performance Comparison
| Operation | Mutex | Atomic | Speed Improvement |
|---|---|---|---|
| Increment | ~100ns | ~1ns | ~100x faster |
| Read | ~50ns | ~1ns | ~50x faster |
| Write | ~50ns | ~1ns | ~50x faster |
Note: Actual performance depends on hardware, contention, and workload.
Questions
Q: What is the difference between std::atomic<int>; and regular int in a multi-threaded context?
Atomic operations are guaranteed to be indivisible - they cannot be interrupted by other threads. Regular int operations (read-modify-write) can be interleaved, causing race conditions.
Q: Which atomic operation should you use to increment a counter safely?
fetch_add() is the correct atomic operation for incrementing. Using load() then store() would create a race condition where the increment could be lost.
Q: What happens if multiple threads call fetch_add() on the same atomic variable?
A: The program crashes
All increments are applied correctly. fetch_add() is atomic, so each thread's increment is guaranteed to be applied without interference from other threads.
Q: When should you use load() vs store() on an atomic variable?
load() is used for reading the current value, while store() is used for writing a new value. This is the standard pattern for atomic operations.
Q: What is the performance difference between atomic and non-atomic operations?
Atomic operations are slower than non-atomic operations because they require memory barriers to ensure proper synchronization across CPU cores.
Q: Which of the following is not a single atomic operation on std::atomic<int> a
a = a + 1 is a load–modify–store sequence, not one atomic RMW. The others are atomic RMWs on std::atomic<int>
Q: Can you use std::atomic with any data type?
std::atomic can only be used with trivially copyable types. This includes integral types, pointers, and simple structs, but not complex objects.