Advanced 13 min

Atomic Operations

Master lock-free thread-safe operations using atomic types for high-performance concurrent programming

Learn how to perform lock-free thread-safe operations using atomic types, enabling high-performance concurrent programming without mutexes.

A Simple Example

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

std::atomic<int> counter{0};
std::atomic<bool> ready{false};
std::atomic<int> data{0};

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

void writer() {
    data.store(42, std::memory_order_relaxed);
    ready.store(true, std::memory_order_release);  // Synchronizes with acquire
}

void reader() {
    while (!ready.load(std::memory_order_acquire)) {
        // Spin until ready
    }
    std::cout << "Data: " << data.load(std::memory_order_relaxed) << "\n";
}

int main() {
    // Example 1: Lock-free counter
    const int iterations{10000};
    std::vector<std::thread> threads;

    for (int i{0}; i < 4; ++i) {
        threads.emplace_back(incrementCounter, iterations);
    }

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

    std::cout << "Counter: " << counter.load() << "\n";
    std::cout << "Expected: " << (4 * iterations) << "\n\n";

    // Example 2: Lock-free synchronization
    std::thread writerThread{writer};
    std::thread readerThread{reader};

    writerThread.join();
    readerThread.join();

    return 0;
}

Breaking It Down

std::atomic basics

  • What it does: Makes operations on a variable indivisible and thread-safe
  • load(): Read the value atomically
  • store(): Write a value atomically
  • Remember: Works for integers, bools, pointers, and simple types

Atomic operations

  • fetch_add(): Add and return old value atomically
  • fetch_sub(): Subtract and return old value atomically
  • compare_exchange_weak(): Compare and swap if equal (can fail spuriously)
  • Remember: These are lock-free and faster than mutex for simple operations

Memory ordering

  • memory_order_relaxed: No ordering guarantees, fastest
  • memory_order_acquire/release: Synchronizes with paired operations
  • memory_order_seq_cst: Strongest guarantee, default, slowest
  • Remember: Use relaxed for independent updates, acquire/release for synchronization

When to use atomics vs mutexes

  • Use atomics: Simple counters, flags, single variable updates
  • Use mutexes: Multiple variables, complex operations, critical sections
  • Trade-off: Atomics are faster but limited to simple operations
  • Remember: Do not prematurely optimize - measure before choosing

Why This Matters

  • Mutexes have overhead - context switching, blocking, and cache effects. Atomic operations are faster for simple shared data.
  • Lock-free algorithms avoid deadlocks, priority inversion, and blocking, making them ideal for real-time systems.
  • Counters, flags, and simple shared state benefit from atomic operations instead of heavyweight mutex locking.

Critical Insight

Atomic operations are like vending machines. You insert money and press a button - the whole transaction happens as one indivisible operation. No one can interrupt halfway through and take your snack.

Memory ordering is about what other threads can see before and after your atomic operation. memory_order_relaxed is like not caring what others see. memory_order_release/acquire is like shouting "I am done!" so others know they can safely read your results.

Best Practices

Start with seq_cst: Use the default memory_order_seq_cst until profiling shows it is a bottleneck.

Use atomics for simple data: Counters, flags, and single variables are perfect for atomics. Complex operations need mutexes.

Pair acquire and release: When synchronizing between threads, use release in the writer and acquire in the reader.

Use relaxed for independent updates: If each update is independent, memory_order_relaxed is safe and fastest.

Do not guess memory ordering: Memory ordering is subtle. Use seq_cst unless you have measured and understand the trade-offs.

Common Mistakes

Using relaxed when you need synchronization: memory_order_relaxed does not synchronize memory between threads.

Overusing atomics: Complex operations still need mutexes. Do not try to build everything with atomics.

Assuming atomics are always faster: For simple cases yes, but complex atomic algorithms can be slower than mutexes.

Incorrect memory ordering pairs: release must be paired with acquire, not relaxed.

Debug Challenge

This atomic operation needs the correct memory ordering for synchronization. Click the highlighted line to fix it:

1 std::atomic<bool> ready{false};
2 int data{0};
3
4 void producer() {
5 data = 42;
6 ready.store(true, std::memory_order_relaxed);
7 }
8
9 void consumer() {
10 while (!ready.load(std::memory_order_acquire));
11 std::cout << data << "\n";
12 }

Quick Quiz

  1. When should you use atomics instead of mutexes?
For simple single-variable operations like counters and flags
For all multithreaded code
When you need to protect multiple variables
  1. What does memory_order_relaxed mean?
Completely thread-safe for all operations
No ordering guarantees, only atomicity
Slower but safer
  1. What is the default memory order for atomic operations?
memory_order_relaxed
memory_order_release
memory_order_seq_cst (sequential consistency)

Practice Playground

Time to try out what you just learned! Play with the example code below, experiment by making changes and running the code to deepen your understanding.

Lesson Progress

  • Fix This Code
  • Quick Quiz
  • Practice Playground - run once