Hello! Welcome back to our course on C++ internals.
In our last lesson, we wrestled with deadlocks, a serious problem that arises from using mutexes. We saw that while locks are crucial for preventing data races, they introduce complexity and potential for application-wide gridlock. This naturally leads to a question: can we achieve thread safety without using locks?
The answer is yes, and it brings us to the frontier of high-performance concurrent programming. Today, we will explore lock-free operations using std::atomic. You will learn what "atomicity" truly means at the hardware level, how to use std::atomic to perform safe, lock-free modifications to shared data, and how this approach can eliminate deadlocks and often improve performance.
This lesson directly addresses your goal of understanding C++ from the ground up, as we'll be connecting the high-level std::atomic library features directly to the low-level CPU instructions and memory synchronization mechanisms that make them possible.
Part 1: Why i++ is Not Your Friend in a Multithreaded World
In a single-threaded program, i++ is a simple, indivisible-seeming operation. In a multithreaded context, it's a data race waiting to happen. To understand why, we need to remember that what looks like a single line of C++ is often multiple machine instructions.
A simple increment operation, i++, typically breaks down into three steps at the machine level:
- Read: Load the current value of
ifrom memory into a CPU register. - Modify: Increment the value in the register.
- Write: Store the new value from the register back into memory.
Now, imagine two threads trying to increment the same shared integer i (initially 0) at the same time:
- Thread A reads the value of
i(0) into its register. - The OS scheduler preempts Thread A and switches to Thread B.
- Thread B reads the value of
i(still 0) into its register. - Thread B increments its register to 1 and writes it back to
i. The value ofiin memory is now 1. - The OS scheduler switches back to Thread A.
- Thread A, unaware of what Thread B did, increments its register (which still holds 0) to 1 and writes it back to
i. The value ofiin memory is again 1.
We performed two increments, but the final result is 1 instead of the correct value of 2. We have lost an update. This is a classic data race, and it leads to undefined behavior.
CppCon 2017: Fedor Pikus “C++ atomics, from basic to advanced. What do they really do?”
The following video from a CppCon talk by Fedor Pikus provides an excellent visualization of this exact problem, showing how CPU caches contribute to this race condition.
Watch from 4:23 to 8:07. Focus on the explanation of the read-modify-write sequence and how two threads can end up with an incorrect result.
Part 2: The Concept of Atomicity
To solve this problem, we need the read-modify-write sequence to be atomic.
An atomic operation is one that executes as a single, indivisible transaction. From the perspective of all other threads, an atomic operation appears to happen instantaneously. They can see the state of the system before the operation or the state after the operation, but never a partially-completed, intermediate state.
More formally, an atomic operation has two crucial properties:
- Indivisibility (or Non-preemptability): The operation cannot be interrupted. For a read-modify-write operation, this ensures that no other thread can modify the shared memory between the read and the write.
- Synchronization (or Visibility): The operation ensures that changes to memory become visible to other threads in a predictable order. This prevents the compiler and CPU from reordering memory operations in ways that would break our logic in a concurrent setting.
Implementing Your Own C++ Atomics - Ben Saks - CppCon 2025
This next clip, from a talk by Ben Saks, provides a clear and concise definition of these two properties, which are the foundation of what it means for something to be atomic.
Watch from 0:58 to 5:15. The speaker introduces the concepts of non-preemptability and synchronizability, which are key to understanding atomics.
Part 3: The C++ Solution: std::atomic
C++11 introduced the std::atomic template library to provide atomic operations. By wrapping a type in std::atomic, you instruct the compiler to use special hardware instructions that guarantee atomicity.
Let's fix our broken counter:
#include <atomic>
#include <thread>
#include <vector>
#include <iostream>
// Shared variable is now atomic
std::atomic<int> counter = 0;
void increment() {
for (int i = 0; i < 10000; ++i) {
// This ++ is now an atomic operation
counter++;
}
}
int main() {
std::vector<std::thread> threads;
for (int i = 0; i < 10; ++i) {
threads.push_back(std::thread(increment));
}
for (auto& th : threads) {
th.join();
}
std::cout << "Final counter value: " << counter << std::endl; // Guaranteed to be 100000
return 0;
}
When the compiler sees counter++ on a std::atomic<int>, it no longer generates separate read, modify, and write instructions. Instead, it emits a single, special atomic instruction. On an x86 architecture, this would typically be a LOCK XADD instruction. The LOCK prefix tells the CPU to gain exclusive access to the memory location for the duration of the XADD (exchange and add) instruction, ensuring no other CPU core can interfere.
This makes the operation indivisible and also creates a memory barrier, which enforces the synchronization property.

Part 4: How Atomicity is Implemented "Under the Hood"
Your interest lies in how things work at a low level, so let's dig deeper. How does the compiler actually guarantee these properties?
There are two main approaches:
-
Compiler Intrinsics: Modern compilers (like GCC, Clang, MSVC) provide built-in functions that map directly to atomic hardware instructions. For example, GCC provides
__atomic_load_n,__atomic_store_n, and__atomic_fetch_add. The C++std::atomiclibrary is typically implemented using these intrinsics. -
Inline Assembly (The Gory Details): On platforms without compiler intrinsics or for custom needs, one can use inline assembly. This gives us a raw view of what's happening.
Implementing Your Own C++ Atomics - Ben Saks - CppCon 2025
The following clip is a fantastic deep dive into how you could implement your own atomics. It demonstrates how to use inline assembly on an embedded platform to disable interrupts (for non-preemptability) and, more importantly for modern multi-core systems, how the "memory" clobber in GCC's inline assembly acts as a full memory barrier to enforce synchronization.
Watch from 43:26 to 55:10. This is the core 'under-the-hood' content. Pay close attention to the explanation of the __asm__ block and the memory clobber. This directly shows how a programmer can enforce memory ordering rules on the compiler and CPU.
The key takeaway from that video is that atomicity isn't magic. It's a precisely defined contract with the hardware, enforced through specific instructions and memory barriers that prevent unwanted reordering and guarantee visibility across CPU cores.
Part 5: A Tour of std::atomic Operations
The std::atomic library provides more than just increment. Let's look at the essential operations.
Which Types Can Be Atomic?
Any trivially copyable type can be used with std::atomic. A type is trivially copyable if it can be safely copied with memcpy. This includes all primitive types (int, char, pointers), and structs/classes composed of such types.
However, just because it compiles doesn't mean it's lock-free! If the hardware doesn't support atomic operations for a given type's size or alignment, the std::atomic object might secretly use a mutex internally. You can check this:
std::atomic<long long> val;
// Compile-time check: is this type *ever* lock-free on this platform?
static_assert(std::atomic<long long>::is_always_lock_free);
// Runtime check: is this *specific object* lock-free?
// (Could be false due to alignment)
if (!val.is_lock_free()) {
std::cout << "Warning: atomic<long long> is not lock-free!\n";
}
Key Operations
For a std::atomic<T> x;:
x.store(value): Atomically writesvaluetox.x.load(): Atomically reads the value ofx.x.exchange(value): Atomically replaces the value ofxwithvalueand returns the old value.x.fetch_add(val),x.fetch_sub(val): Atomically performs addition/subtraction and returns the old value. These are true atomic read-modify-write operations. The++and--operators are syntactic sugar for these.x.compare_exchange_strong(expected, desired): The workhorse of lock-free algorithms. It works like this:- Atomically compares the value of
xwithexpected. - If they are equal, it replaces
x's value withdesiredand returnstrue. - If they are not equal, it updates
expectedwith the current value ofxand returnsfalse.
- Atomically compares the value of
Test your understanding!
You have an std::atomic<int> x = 10;. You write the expression x = x + 1;. Is this entire expression atomic? Why or why not?
Show answer
No, it is not. This expression is broken down into two separate atomic operations: an atomic load() of x, followed by a separate atomic store() of the result. Another thread could modify x in between the load and the store, leading to a lost update. This is why you must use the single read-modify-write member functions like x++, x += 1, or x.fetch_add(1).
CppCon 2017: Fedor Pikus “C++ atomics, from basic to advanced. What do they really do?”
Let's return to Fedor Pikus's talk, where he gives a great overview of the different atomic operations and highlights the subtle but critical difference between x++ and x = x + 1.
Watch from 10:25 to 20:01. This section covers which types can be atomic, the overloaded operators vs. member functions (fetch_add), and introduces compare-and-swap (CAS).
Part 6: Implementing with Atomics: A Lock-Free Counter
Let's put this into practice by implementing a simple thread-safe counter. This fulfills the "implement" part of today's learning outcome.
First, here's a version using a mutex, similar to what we've used before:
#include <mutex>
class CounterMutex {
public:
CounterMutex() : value(0) {}
void increment() {
std::lock_guard<std::mutex> lock(mtx);
value++;
}
int get() {
std::lock_guard<std::mutex> lock(mtx);
return value;
}
private:
std::mutex mtx;
int value;
};
Now, let's implement the same thing using std::atomic. Notice how much simpler it is.
#include <atomic>
class CounterAtomic {
public:
CounterAtomic() : value(0) {}
// Use fetch_add for an atomic read-modify-write
void increment() {
value.fetch_add(1, std::memory_order_relaxed);
}
// Use load for an atomic read
int get() {
return value.load(std::memory_order_relaxed);
}
private:
std::atomic<int> value;
};
Note: I've used std::memory_order_relaxed here. For a simple counter, this is often sufficient and can be more performant. For now, think of this as the "fastest but weakest" ordering. The default is std::memory_order_seq_cst, which is the strongest and safest. We will cover memory orders in detail in the next lesson.
The atomic version is not only free from the possibility of deadlock, but it also avoids the overhead of acquiring and releasing a mutex, which can involve system calls to the OS kernel. Atomic operations, when truly lock-free, are handled entirely in hardware and are typically much faster under contention.
Conclusion
Today we've taken our first major step beyond lock-based concurrency. We've seen that std::atomic provides the tools to build thread-safe code without mutexes, eliminating an entire class of bugs like deadlock.
Key Takeaways:
- Atomicity means an operation is indivisible (can't be interrupted) and enforces synchronization (makes memory changes visible in a predictable order).
- Simple operations like
i++on non-atomic types are not atomic and create data races in multithreaded code. std::atomicmaps C++ operations to special atomic hardware instructions (likeLOCK XADDon x86) that guarantee atomicity.- These instructions also act as memory barriers, preventing the compiler and CPU from performing harmful instruction reordering.
- Read-Modify-Write (RMW) operations like
fetch_addandcompare_exchange_strongare the fundamental building blocks for lock-free algorithms. - You can check if an atomic type is truly lock-free on your platform using
is_lock_free().
What's Next?
We've only scratched the surface. I mentioned std::memory_order briefly. The default, seq_cst, provides "sequential consistency"—the easiest to reason about but also the most expensive. To write high-performance lock-free code, we need to understand the different memory ordering options (relaxed, acquire-release) and the trade-offs they offer.
In our next lesson, we will dive deep into the C++ memory model and analyze these different memory orderings, giving you the power to fine-tune the performance and synchronization behavior of your atomic operations.