Skip to Content
Course content

138: Atomic Operations with stdatomic.h

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

If you've been working with pthreads or any multi-threaded environment, you've probably relied on mutexes to protect shared data. Mutexes are the "big hammer" of synchronization—they work, but they're heavy. Today, I want to show you a more surgical approach using stdatomic.h. This is particularly useful when you just need to update a simple counter or a flag without the overhead of locking and unlocking a mutex every few nanoseconds.

The Illusion of the Simple Increment

Let's start with how most people intuitively write a shared counter. Imagine we're building a high-performance packet processor where multiple threads are counting the total number of packets received. You might be tempted to do something like this:

int total_packets = 0;

void process_packet() {
    // ... some processing logic ...
    total_packets++; 
}

On the surface, total_packets++ looks like a single operation. In reality, the compiler breaks this down into three distinct CPU instructions: load the value from memory into a register, increment the register, and store the value back into memory. This is the "Read-Modify-Write" (RMW) cycle.

Where the Threads Clash

Here is where it breaks. If Thread A loads the value (say, 10) and then the OS decides to context-switch to Thread B before Thread A can write it back, Thread B also loads 10. Both threads increment their local registers to 11. Thread A writes 11, and then Thread B writes 11. We just processed two packets, but our counter only went up by one. This is a classic race condition, and the worst part is that it's non-deterministic; your code might work perfectly on your machine but fail miserably under heavy load in production.

Now, you could wrap that increment in a pthread_mutex_lock and unlock. That solves the correctness problem, but for a simple integer, the cost of the mutex (which involves system calls and potential thread sleeping) is often orders of magnitude more expensive than the increment itself. It's like hiring a security guard to watch a single penny.

Letting the Hardware Handle the Heavy Lifting

This is where stdatomic.h comes in. Introduced in C11, it allows us to tell the compiler and the CPU: "This operation must be atomic." An atomic operation is guaranteed to be completed as a single, indivisible unit from the perspective of other threads.

Here is how I would rewrite that packet counter to be both thread-safe and performant:

#include <stdatomic.h>

atomic_int total_packets = 0;

void process_packet() {
    // ... some processing logic ...
    atomic_fetch_add(&total_packets, 1);
}

By using atomic_int and atomic_fetch_add, we aren't using a software lock. Instead, the compiler leverages specific CPU instructions (like LOCK XADD on x86) that lock the memory bus or a cache line for just that one operation. The hardware ensures that no other thread can intervene during the Read-Modify-Write cycle.

Choosing Between Atomics and Mutexes

You might be wondering why we don't just make everything atomic. There's a trade-off. Atomics are fantastic for "primitive" state—counters, flags, or pointers. However, they don't scale to complex data structures. You can't make a whole struct or a linked list "atomic" in a way that prevents logic errors across multiple fields.

If you need to update three different variables and ensure that no thread sees a "partial" update where only two are changed, you still need a mutex. Atomics protect the variable; mutexes protect the invariant. I usually suggest using atomics for telemetry, reference counting, and simple state signaling, but sticking to mutexes for anything that involves more than one piece of related data.




📋 Practical Task

Implementing a Lock-Free Thread-Safe Telemetry Counter

Your task is to create a program that simulates a high-traffic server receiving requests across multiple threads. You must implement a shared telemetry counter that tracks the total number of requests processed without using pthread_mutex_t.

  • Create a program that spawns 10 threads using pthread_create.
  • Each thread should loop 100,000 times, incrementing a shared global counter using stdatomic.h.
  • The main thread must wait for all threads to finish using pthread_join.
  • After all threads complete, print the final count. The result must be exactly 1,000,000 every single time you run it.
  • Challenge: Try replacing the atomic counter with a standard int and counter++ to observe the race condition and see how far off the final total is.
Rating
0 0

There are no comments for now.

to be the first to leave a comment.