C
Completed
-
Section 1: Getting Started
-
Section 2: Core Syntax
-
Section 3: Functions
-
Section 4: Arrays and Strings
-
Section 5: Pointers
-
Section 6: Memory Management
-
Section 7: Structures and Unions
-
Section 8: The Preprocessor and Build Process
-
Section 9: Standard Library: stdio.h
-
Section 10: Standard Library: stdlib.h
-
Section 11: Standard Library: string.h
-
Section 12: Standard Library: ctype.h and wctype.h
-
Section 13: Standard Library: math.h, complex.h, fenv.h, tgmath.h
-
Section 14: Standard Library: Type and Limit Headers
-
Section 15: Standard Library: Error Handling and Debugging
-
Section 16: Standard Library: Localization and Encoding
-
Section 17: Standard Library: time.h
-
Section 18: Standard Library: Concurrency (C11)
-
Section 19: POSIX and System Programming (unistd.h)
-
Section 20: More Data Structures
-
Section 21: Algorithms in C
-
Section 22: Bitwise Operations
-
Section 23: Command-Line Programs
-
Section 24: Debugging and Best Practices
-
Section 25: Compiler and Language Internals
-
Section 26: Embedded and Cross-Platform Considerations
-
Section 27: Networking Basics
-
Section 28: Practical Projects
-
Section 29: Interview Practice
-
Section 30: C23 Modern Features
-
Section 31: More Practice and Review
138: Atomic Operations with stdatomic.h
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
intandcounter++to observe the race condition and see how far off the final total is.
There are no comments for now.