Hello! In our previous lessons, we've explored several concurrency primitives, including std::promise and std::future. A common thread has been their reliance on lower-level constructs like mutexes and condition variables to ensure correctness. These are examples of lock-based concurrency.
This raises a natural question: can we achieve thread safety without using locks? The answer is yes, through lock-free programming using std::atomic. However, this is not a simple replacement. The choice between lock-based and lock-free approaches involves a complex set of trade-offs in performance, complexity, and correctness.
Today, we will directly address this by comparing the trade-offs between lock-based and lock-free concurrency. Your goal is to understand when one approach might be chosen over the other, not just in terms of raw speed, but also in terms of system behavior and developer effort. We'll move beyond the "lock-free is always faster" myth and develop a nuanced, principled understanding of the costs and benefits.
1. The Problems with Locks
Lock-based programming is the standard, default way to handle concurrency. You identify a shared resource, place a mutex next to it, and ensure every access is guarded by a lock. While conceptually simple, this approach has well-known drawbacks, especially in large, complex systems.
To understand the motivation for even considering lock-free alternatives, let's start with a talk by Herb Sutter, a leading expert in C++.
CppCon 2014: Herb Sutter "Lock-Free Programming (or, Juggling Razor Blades), Part I"
Watch this segment from Herb Sutter's classic talk, 'Lock-Free Programming (or, Juggling Razor Blades)'. He explains why we seek alternatives to locks and introduces a powerful analogy to frame the discussion.
Please watch from 0:40 to 07:10. The first part (0:40 - 3:24) covers the fundamental problems with locks: deadlock, forgotten locks, and the tension between fine-grained locking for scalability and coarse-grained locking for simplicity. The second part (4:48 - 7:10) introduces the analogy of locks as 'traffic lights' and lock-free approaches as 'cloverleaf interchanges'. Pay close attention to this analogy as it beautifully captures the different philosophies.
As Sutter explains, locks are a form of blocking synchronization. If Thread A holds a lock, and Thread B needs it, Thread B must block (stop making progress) until Thread A releases the lock. This can lead to several problems:
- Deadlock: The classic issue where two or more threads are waiting for each other to release locks.
- Priority Inversion: A high-priority thread gets stuck waiting for a low-priority thread that holds a lock, because the OS scheduled an unrelated medium-priority thread instead of the lock-holding thread.
- Convoying: If a thread holding a lock is descheduled by the OS (e.g., its time slice ends, or it takes a page fault), all other threads waiting for that lock form a "convoy" and grind to a halt. The system's overall throughput suffers.
- Overhead: In uncontended cases, locks are fast. But when contention occurs and blocking is necessary, a lock might involve expensive system calls and context switches to put a thread to sleep and wake it up later.
Lock-free programming aims to solve these issues by ensuring that at any point in time, at least one thread in the system is making progress. This is what we call a non-blocking guarantee.
2. The Case Against Lock-Free: Juggling Razor Blades
Given the problems with locks, it's tempting to think lock-free programming is the superior solution. This is a dangerous misconception. Lock-free code is significantly more complex to write correctly and does not automatically guarantee better performance.
Let's start by immediately debunking the "lock-free is always faster" myth with a powerful counterexample.
CppCon 2017: Fedor Pikus “C++ atomics, from basic to advanced. What do they really do?”
This clip from Fedor Pikus's talk on C++ atomics starts with a benchmark that will likely surprise you. He compares a lock-based and a wait-free (a stronger form of lock-free) implementation for summing numbers.
Watch from 01:09 to 04:06. Focus on the benchmark result and the explanation of the two different algorithms. This highlights the most important principle in concurrent performance.
As you just saw, the lock-based version scaled almost linearly, while the "superior" wait-free version showed no speedup at all and was significantly slower. Why?
Trade-off 1: Algorithm vs. Primitive
The core takeaway from that example is that the algorithm's design dominates the choice of primitive.
- Lock-Free (Bad Algorithm): Every thread hammered on a single atomic variable (
atomic_sum += ...). This creates high contention at the hardware level. Even without a software lock, the CPU cores are fighting for exclusive access to the same cache line. This is called true sharing. - Lock-Based (Good Algorithm): Each thread summed its portion into a local, non-shared variable. Only at the very end did it take a lock to add its local sum to the global total. The lock was held for a tiny fraction of the total time.
The real performance win came from avoiding contention, not from avoiding locks. This principle is universal.
Trade-off 2: New Kinds of Bugs (The ABA Problem)
Lock-free algorithms, which are typically built around a Compare-And-Swap (CAS) loop, introduce subtle correctness bugs that don't exist in lock-based code. The most famous is the ABA problem.
Imagine a lock-free stack implemented with an atomic pointer top. A thread wants to pop an element:
- Thread 1 reads
top, which points to nodeA. It calculates that the new top should beA->next, which is nodeB. - Before Thread 1 can execute its CAS, the OS deschedules it.
- Thread 2 runs. It pops
A, then popsB. Then, it allocates new memory for a different value, and the memory allocator happens to give it the same address thatAused to have. Thread 2 pushes this new node (let's call itA') onto the stack. The stack is nowA' -> C. - Thread 1 is rescheduled. It proceeds with its original plan:
compare_exchange(top, A, B). It readstop, seesA'which has the same address asA, so the comparison succeeds! It incorrectly setstoptoB. - The stack is now corrupted. The
toppointer points toB, which has already been popped and possibly deleted. The nodeA'has been lost.
Fine-grained synchronization & lock-free programming
These presentation slides from Carnegie Mellon University give a concise explanation of the ABA problem.
Please review the slide titled 'The ABA problem'. It provides a clear, step-by-step visualization of how this error occurs. You don't need to read the surrounding slides in detail, just focus on understanding this specific failure mode.
This problem is solvable (e.g., by tagging the pointer with a version counter), but it demonstrates the immense complexity and subtlety required. You are no longer just protecting a block of code; you are reasoning about the state of the entire system between individual CPU instructions.
Trade-off 3: Hardware Performance Pitfalls (False Sharing)
Even when your lock-free algorithm is logically correct, you can face performance issues rooted in the hardware architecture. The most common is false sharing.
CPU caches don't operate on single bytes; they manage memory in chunks called cache lines (typically 64 bytes on x86).
- Imagine you have two atomic integers,
XandY, that happen to be located next to each other in memory and fall on the same cache line. - Thread 1 on Core 1 exclusively modifies
X. - Thread 2 on Core 2 exclusively modifies
Y.
Logically, these threads should not interfere. But because X and Y share a cache line, the hardware's cache coherency protocol will cause the line to be bounced back and forth between Core 1 and Core 2. Each time a thread needs to write, it must invalidate the other core's copy and wait to gain exclusive ownership of the line. The threads are contending at the hardware level, even though they aren't sharing data at the software level.
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 explains this exact phenomenon and shows a benchmark demonstrating its impact.
Watch from 29:51 to 33:12. This part explains the concept of cache line sharing and shows that two threads modifying different atomics on the same cache line perform as poorly as threads modifying the same atomic. Then, jump to 01:06:56 - 01:10:49, where he confirms that this cache line bouncing was the cause of the slow performance in his initial benchmark.
3. A Structured View: The Concurrency Cost Hierarchy
To make sense of these performance trade-offs, it's helpful to have a quantitative framework. The blog post "A Concurrency Cost Hierarchy" by Travis Downs provides an excellent one. It categorizes concurrent operations into levels, where each level is roughly an order of magnitude more expensive than the last.
Let's use this hierarchy to compare our options.
This article provides a fantastic, data-driven comparison of different synchronization methods. We'll read it in a few parts.
First, read the section 'Level 2: Contended Atomics'. This directly compares a std::mutex against atomic increment and CAS under high contention. Then, skim the sections 'Level 3: System Calls' and 'Level 4: Implied Context Switch' to understand the costs that locks can incur when they block.
From that reading, you should see two key points:
- Under high contention,
std::mutexperforms surprisingly well, even beating a CAS-based atomic approach in some cases. It's not orders of magnitude worse than a contended atomic operation. - The huge costs of locking come from Levels 3 and 4, when blocking leads to system calls and, even worse, context switches. A lock-free approach is immune to these specific costs because it is non-blocking.
Now for the punchline. Where do the biggest performance wins come from?
Continue reading 'A Concurrency Cost Hierarchy'.
Now, read the sections 'Level 1: Uncontended Atomics' and 'Level 0: Vanilla'. Pay close attention to the 'cas multi' and 'tls add' implementations. Notice how they achieve massive speedups.
The lesson is clear: the jump from Level 2 (contended) to Level 1 (uncontended) is where the real magic happens. This was achieved not by switching from locks to atomics, but by changing the algorithm to eliminate contention (using multiple counters or thread-local storage). This powerfully reinforces the point from Fedor Pikus's talk.
Test your understanding!
Imagine you are designing a system to count the number of requests per second hitting a web server with 32 cores. Millions of requests arrive per second. You need the counter to be thread-safe.
Based on the "Concurrency Cost Hierarchy", which of the following two initial designs is likely to perform better and why?
- A single
std::atomic<uint64_t> global_counter;which every request-handling thread increments. - An array of 32 padded
uint64_tcounters. Each thread increments the counter corresponding to its core ID. A separate monitoring thread occasionally sums up the 32 counters to get the total.
Show answer
Design #2 is vastly superior.
- Design #1 operates at Level 2 (True Sharing). All 32 cores would constantly contend for the cache line holding
global_counter. The performance would be poor and would not scale as you add more cores. This is exactly the scenario from the first benchmark in Fedor Pikus's talk. - Design #2 operates at Level 0 (Vanilla). Each thread modifies its own private counter. There are no atomic operations needed for the increment, and no contention between threads. The padding prevents false sharing. This design scales perfectly. The cost is shifted to the
readoperation, which is infrequent, making it an excellent trade-off. This matches the 'per-CPU' or 'tls add' patterns from Travis Downs's article.
Summary of Trade-offs
Let's summarize the comparison in a table.
| Aspect | Lock-Based Concurrency (std::mutex) |
Lock-Free Concurrency (std::atomic) |
|---|---|---|
| Philosophy | Blocking. Protects a critical section of code. Simpler to reason about: "only one thread at a time." | Non-Blocking. Ensures system-wide progress. Extremely difficult to reason about all possible interleavings. |
| Correctness Risks | Deadlock, forgotten locks. Generally easier to debug. | ABA problem, data races from incorrect memory ordering, subtle hardware effects. Extremely hard to prove correct. |
| Robustness | Prone to priority inversion and convoying if a thread is descheduled while holding a lock. | Immune to blocking-related problems. Ideal for contexts where blocking is unacceptable (e.g., interrupt handlers). |
| Performance | Can be slow under high contention due to OS scheduling (syscalls, context switches). | Can be slow under high contention due to hardware effects (cache line bouncing, true/false sharing). |
| Best Case | Low contention. The lock is almost always free, acquisition/release is very fast. | No contention. Atomic operations are very fast on their own. |
| When to Use | The default choice for almost all application-level concurrency. | A specialist tool for expert developers building low-level, performance-critical data structures (e.g., schedulers, queues, memory allocators). |
Conclusion
You came into this course wanting to understand how things work "under the hood," and the trade-offs between lock-based and lock-free concurrency are a perfect example of why that matters. A surface-level understanding might lead you to believe "lock-free is faster," but a deep, principled analysis reveals a much more nuanced reality.
Key Takeaways:
- Lock-based concurrency is blocking and can suffer from deadlock, priority inversion, and convoying.
- Lock-free concurrency is non-blocking but introduces extreme complexity and new classes of bugs like the ABA problem.
- Performance is not guaranteed. A naive lock-free algorithm with high contention is often slower than a well-designed lock-based algorithm that minimizes contention.
- The greatest performance gains come from algorithmic changes that reduce or eliminate contention, not from simply swapping a
mutexfor anatomic. - Lock-free programming is an expert-level optimization, like "juggling razor blades." For most problems, a well-structured lock-based approach is the correct, safer, and often equally performant choice.
In the next module, we will begin our journey into C++ Design Patterns. We'll start with the foundational creational patterns, beginning with the Singleton. You'll see how to implement it in a thread-safe way, applying the principles of concurrency we've been discussing to a higher-level architectural problem.