C++
Completed
-
Section 1: Getting Started
-
Section 2: Core Syntax
-
Section 3: Object-Oriented C++
-
Section 4: Memory Management
-
Section 5: Templates and Generic Programming
-
Section 6: The Standard Template Library
-
Section 7: Modern C++ Features
-
Section 8: Error Handling
-
Section 9: Multithreading
-
Section 10: Operator Overloading and Type Conversion
-
Section 11: Advanced Topics
-
Section 12: Tooling and Build Systems
-
Section 13: Design Patterns in C++
-
Section 14: Interfacing with C and Systems Programming
-
Section 15: Networking and IPC Basics
-
Section 16: Graphics and Game Programming Basics
-
Section 17: The Boost Libraries
-
Section 18: Data Structures and Algorithms in C++
-
Section 19: Practical Projects
-
Section 20: More Concurrency Patterns
-
Section 21: More OOP and Design Practice
-
Section 22: File I/O and Streams
-
Section 23: More Standard Library
-
Section 24: Practice Exercises
-
Section 25: Interview and Algorithm Practice
-
Section 26: Compiler and Language Internals
-
Section 27: GUI and Application Frameworks Overview
-
Section 28: Testing and Quality Practices
-
Section 29: Numerics Library
-
Section 30: Concepts Library (C++20)
-
Section 31: Ranges Library (C++20) In Depth
-
Section 32: More Utility Library
110: Atomics and Lock-Free Basics
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 acompare_exchange_strongloop to update the maximum only if the provided value is greater than the existing maximum. - Implement a method
int getMax() constthat returns the current maximum. - Create a
mainfunction that spawns 10 threads, each reporting a random set of integers, and then verify that the final maximum is correct.
There are no comments for now.