Skip to content

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 back

Multiple 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 XOR

Increment/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

OperationMutexAtomicSpeed 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.