Hello! In our last lesson, we successfully assembled a complete, reusable thread pool. This powerful tool relies on mutexes and condition variables for synchronization, which are robust but can sometimes become performance bottlenecks due to lock contention.
Today, we transition from lock-based synchronization to a new paradigm: lock-free programming. We'll explore how to ensure thread safety for simple operations without using locks at all. Your learning outcome for this lesson is to use C11 atomic types (_Atomic) to implement a lock-free counter. This is a foundational skill for writing high-performance concurrent code.
We'll start by looking at the problem of concurrent updates and then see how C11 atomics provide an elegant and efficient solution.

The Race Condition Revisited
You're already familiar with race conditions. When multiple threads try to update a shared variable, operations that seem simple in C, like counter++, are not "atomic" at the machine level. They typically involve three separate steps:
- Read the current value of
counterfrom memory into a register. - Increment the value in the register.
- Write the new value from the register back to memory.
If Thread A reads the value, and then, before it can write its new value back, Thread B reads the same original value, one of the increments will be lost. This is precisely the problem that mutexes solve by ensuring only one thread can be inside this three-step "critical section" at a time.
The C11 Atomic Solution
Instead of using a mutex, which involves asking the operating system to manage a lock, we can instruct the compiler to use special hardware-level atomic instructions. This is the core idea behind C11's atomic types.
The following video provides a clear demonstration of this concept. It starts with a classic race condition and then fixes it with a single C11 keyword.
Watch this video 'Making variables atomic in C' by Jacob Sorber. It will show you the problem and the simplest solution using the _Atomic keyword.
Watch from the beginning to 04:49. The first part (until 03:35) sets up a simple program with a race condition on a shared counter, which will be familiar to you. The key part is from 04:09 to 04:49, where he adds the _Atomic keyword and the race condition disappears.
As you saw, simply changing the declaration from uint64_t counter; to _Atomic uint64_t counter; was enough to make the program correct.
This works because _Atomic is a type qualifier. When you apply it to a variable, you are telling the compiler that all operations on this variable must be atomic. For simple arithmetic operators like ++, --, +=, -=, etc., the compiler will automatically replace the standard, non-atomic code with a single, atomic hardware instruction.
The Stack Overflow answer by C expert Jens Gustedt provides a very terse summary of this:
Answer to 'Making data reads/writes atomic in C11'
This brief text confirms how _Atomic works. It's a quick read that reinforces the concept.
Read the short, top-voted answer. Note how it explicitly states that operations like toto += 2; become atomic read-modify-write operations automatically.
Why Atomics are Fast
The reason atomics can be much faster than mutexes is that they avoid the overhead associated with locks.
- Mutexes often require a system call to the OS kernel to acquire or release a lock, which can involve context-switching a thread if the lock is contended. This is a relatively "heavyweight" operation.
- Atomics typically compile down to single machine instructions (like
LOCK INCorXADDon x86) that the CPU guarantees will execute indivisibly. This is a "lightweight" hardware-level feature.
The video you watched earlier continues to explain and demonstrate this performance difference.
Let's revisit the video to understand the performance implications.
Watch from 05:11 to 06:08. The author compares the runtime of the atomic version with a mutex-based version. Observe the significant speedup and his explanation that the compiler is using atomic operations built into the processor.
Implementing a Lock-Free Counter: Two Approaches
You can use C11 atomics in two main ways: using the _Atomic keyword, or using the types and functions from the <stdatomic.h> header.
Approach 1: The _Atomic Keyword
This is the method we've seen so far. It's simple and often sufficient for basic operations.
Here is a complete example. You can compile this with gcc -std=c11 -pthread your_file.c -o your_program.
#include <stdio.h>
#include <pthread.h>
#include <stdatomic.h> // Not strictly required for _Atomic, but good practice
// Using the _Atomic keyword to declare an atomic integer.
_Atomic long long counter = 0;
void* worker(void* arg) {
for (int i = 0; i < 1000000; i++) {
// The ++ operator on an _Atomic variable is an atomic operation.
counter++;
}
return NULL;
}
int main() {
pthread_t t1, t2;
pthread_create(&t1, NULL, worker, NULL);
pthread_create(&t2, NULL, worker, NULL);
pthread_join(t1, NULL);
pthread_join(t2, NULL);
printf("Final counter value: %lld\n", counter); // Will correctly print 2000000
return 0;
}
Approach 2: Using Functions from <stdatomic.h>
The C11 standard also provides a library of functions for working with atomic types. This approach can be more explicit and is necessary for more complex atomic operations that we'll see later.
The most common function for a counter is atomic_fetch_add().
What is atomic_fetch_add in C?
This article explains the atomic_fetch_add function and provides a clear code example comparing its use against a non-atomic counter.
Read the 'Functionality', 'Parameters and return', and 'Code' sections. Pay attention to the function prototype and what it returns. The code example clearly demonstrates its usage and the resulting correct output.
Using this function, our worker code would look like this. Note that we also use atomic_llong from <stdatomic.h> as an alternative way to declare the atomic variable.
#include <stdio.h>
#include <pthread.h>
#include <stdatomic.h>
// Using the atomic type 'atomic_llong' from the header file.
atomic_llong counter = 0;
void* worker(void* arg) {
for (int i = 0; i < 1000000; i++) {
// Atomically adds 1 to counter and returns the old value (which we ignore here).
atomic_fetch_add(&counter, 1);
}
return NULL;
}
// ... main function is identical ...
For a simple increment, counter++ and atomic_fetch_add(&counter, 1) achieve the same goal. The function-based approach offers more control and is part of a wider family of atomic functions (e.g., atomic_exchange, atomic_compare_exchange_strong) that enable more complex lock-free algorithms.
Critical Caveats: The Limits of Atomicity
While powerful, atomics are not a magic bullet. Using them correctly requires understanding their limitations. Misunderstanding these can lead to subtle and dangerous bugs.
Test your understanding!
Given an atomic integer declared as _Atomic int x = 0;, which of the following operations is NOT guaranteed to be atomic?
x++;x += 5;x = x + 1;atomic_store(&x, 10);
Show answer
The correct answer is 3. x = x + 1;.
This expression involves a separate read (x), an addition, and then a write (x = ...). The _Atomic qualifier does not guarantee that this entire sequence is atomic. It will result in a separate atomic read followed by a separate atomic write, leaving a window for a race condition.
In contrast, x++ and x += 5 are compound assignment operators that the compiler explicitly translates into single atomic read-modify-write instructions. atomic_store is an explicit atomic write function.
The video from Jacob Sorber concludes with an excellent discussion of these important caveats.
This is perhaps the most important part of the lesson for practical application. It covers the 'gotchas' of using atomics.
Watch from 06:08 to 11:02. Pay close attention to the three main points: Optional support: _Atomic is technically an optional feature of C11. Naming: The suggestion to include atomic in variable names to prevent confusion. Expression Atomicity: The critical demonstration that atomic_counter += i is atomic, but the logically equivalent atomic_counter = atomic_counter + i is not.
The key takeaway is that atomicity is guaranteed for single operations (a read, a write) and for specific read-modify-write operators (++, --, +=, &=, etc.). It is not guaranteed for arbitrary expressions that may involve multiple atomic variables or multiple steps.
Conclusion
In this lesson, you've taken your first step into lock-free programming. You've seen that for simple, shared data like counters, C11 atomics provide a high-performance alternative to mutexes by leveraging special hardware instructions.
Key Takeaways:
- C11 provides lock-free synchronization via the
_Atomickeyword and the functions in<stdatomic.h>. - Declaring a variable as
_Atomicensures that simple operations like++and+=are performed atomically, preventing race conditions. - Atomic operations are generally faster than mutexes because they avoid the overhead of kernel-level locking and context switches.
- Crucially, atomicity is not automatic for complex expressions. An operation like
x = x + 1on an atomicxis not atomic and can still lead to race conditions. - You can implement a lock-free counter using either
_Atomic int c; c++;oratomic_int c; atomic_fetch_add(&c, 1);.
Preview of the Next Lesson:
Our use of atomics has implicitly relied on the processor to do the "right thing." But what happens when the compiler or CPU reorders instructions to optimize performance? This can break more complex concurrent algorithms in very non-obvious ways. In our next lesson, we will explore this phenomenon by learning to describe how compiler and processor instruction reordering can cause concurrency bugs, which will lead us to the topic of memory barriers (fences).