Skip to Content
Course content

110: Atomics and Lock-Free Basics

Click on the "Edit" button in the top corner of the screen to edit your slide content.

A few years ago, I was reviewing a PR for a colleague working on a high-frequency telemetry system. He had implemented a simple global counter to track the number of packets processed across twelve different threads. To make it "safe," he wrapped the counter in a std::mutex. On his local machine, it worked fine. But in production, the system hit a wall. The profiler showed that the threads were spending 40% of their time just waiting for that single mutex to unlock. He had created a massive bottleneck—a "convoy" where every thread was essentially standing in line to increment a single integer. This is the classic "mutex tax," and it's exactly why we need atomics.

The Cost of Locking vs. Atomic Operations

When you use a mutex, you're asking the operating system to manage thread ownership. If the lock is held, the OS might put your thread to sleep (a context switch), which is an expensive operation. For a simple operation like incrementing a counter, the overhead of the lock is orders of magnitude larger than the work being performed.

std::atomic changes the game by leveraging hardware-level instructions. Most modern CPUs have built-in support for "atomic" operations—instructions that the hardware guarantees will happen as a single, indivisible unit. When you use std::atomic<int>, the compiler doesn't generate a lock; it generates a specific CPU instruction (like LOCK XADD on x86) that ensures no other core can interfere with that memory location during the operation. It's faster, and more importantly, it's non-blocking.

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

std::atomic<int> packet_count{0};

void process_packets() {
    for (int i = 0; i < 1000; ++i) {
        // This is an atomic fetch-and-add. No mutex required.
        packet_count++; 
    }
}

int main() {
    std::vector<std::thread> threads;
    for (int i = 0; i < 12; ++i) threads.emplace_back(process_packets);
    for (auto& t : threads) t.join();
    
    std::cout < "Total packets: " < packet_count < "\n";
    return 0;
}

The Core of Lock-Free Logic: Compare-and-Swap

Incrementing a number is great, but what if you need to perform a more complex update? For example, imagine you want to update a shared "High Score" value, but only if the new score is actually higher than the current one. You can't just use a mutex if you want to stay lock-free.

This is where compare_exchange_strong comes in. This is the "atomic primitive" that almost all lock-free data structures are built upon. It essentially says: "Look at this memory location. If it still contains the value I think it does, change it to this new value. If it has changed since I last looked, tell me what the current value is and fail."

I usually describe this as an "optimistic" approach. You assume you can update the value without interference, and if you're wrong, you just loop and try again. It's a "retry loop," and it's the heartbeat of lock-free programming.

std::atomic<int> high_score{0};

void update_score(int new_score) {
    int current_score = high_score.load();
    
    // We only want to update if the new score is actually higher
    while (new_score > current_score && 
           !high_score.compare_exchange_strong(current_score, new_score)) {
        // If compare_exchange fails, 'current_score' is automatically 
        // updated with the latest value from 'high_score', 
        // so the while-loop condition is re-evaluated correctly.
    }
}

Dealing with Memory Ordering

If you look at the documentation for std::atomic, you'll see a terrifying parameter called std::memory_order. By default, all atomics use std::memory_order_seq_cst (sequentially consistent). This is the safest mode; it ensures that all threads see all atomic operations in the exact same order.

However, seq_cst comes with a performance penalty because it forces the CPU to synchronize caches across cores. In high-performance code, you'll see std::memory_order_relaxed. This tells the compiler: "I only care that this specific operation is atomic; I don't care about the order of other memory reads and writes around it."

My advice? Stick to the defaults. Unless you are writing a custom memory allocator or a world-class lock-free queue and you've spent a week staring at a memory fence diagram, seq_cst is your friend. Prematurely optimizing memory orders is a fast track to bugs that only happen once every ten thousand hours of runtime.




📋 Practical Task

Exercise: Building a Lock-Free Maximum-Value Tracker

Your task is to implement a class called LockFreeMaxTracker. This class should allow multiple threads to report values, and it should always maintain the highest value ever reported without using a std::mutex or std::lock_guard.

Requirements:

  • Use std::atomic<int> to store the current maximum value.
  • Implement a method void reportValue(int value) that uses a compare_exchange_strong loop to update the maximum only if the provided value is greater than the existing maximum.
  • Implement a method int getMax() const that returns the current maximum.
  • Create a main function that spawns 10 threads, each reporting a random set of integers, and then verify that the final maximum is correct.
Rating
0 0

There are no comments for now.

to be the first to leave a comment.