Skip to main content

Welcome to a Grasp lesson

Create a free account to keep studying and create your own study path.

Lesson 21

Understanding Lock Contention and False Sharing Performance Costs

Recommended resources

Hello! Welcome back.

In our last lesson, we built a fully functional producer-consumer queue using std::mutex and std::condition_variable. We now have a solution that is correct—it safely passes data between threads without corruption. But in the world of high-performance systems, correctness is just the entry ticket. Is our solution fast?

Today, we'll explore two of the most significant performance bottlenecks in concurrent C++: lock contention and false sharing. Our learning outcome is to explain the performance cost of lock contention and false sharing.

Understanding these concepts is absolutely essential for your goal of building a telemetry engine. A profiler that slows down the application it's measuring—the "observer effect"—is fundamentally flawed. By mastering these topics, you'll understand why simple lock-based queues don't scale and why high-performance tools like Tracy use more advanced, often lock-free, designs.

Lock Contention: The Traffic Jam

In our previous lessons, you've already seen a primitive form of contention: the busy-wait loop. Let's start there to build our intuition.

C++ Multithreading Explained: Mutexes, Locks, and Queues

Remember the 'busy-wait' problem we solved with condition variables? The video 'C++ Multithreading Explained' describes this as the consumer thread spinning in a tight loop, constantly trying to acquire a lock. This is a classic example of lock contention.

Please watch from 10:09 to 11:25. Pay attention to the description of 'lock contention' and how the thread burns CPU cycles just trying to get access to the mutex. This is what we mean when we say a thread is 'starved' or 'stalled'.

Lock contention is the general term for any situation where multiple threads are delayed because they are all trying to acquire the same lock. When one thread holds the lock, all other threads that need it must wait. If the lock is held for a long time or requested very frequently, your threads spend more time waiting in line than doing useful work, completely undermining the purpose of parallelism.

So, are locks just slow? Not necessarily. An uncontended lock (one that is acquired without any other thread wanting it at the same time) is extremely fast. The problem isn't the lock itself; it's the contention for it.

The following blog post provides an excellent analysis of this distinction.

Locks Aren't Slow; Lock Contention Is

The article 'Locks Aren't Slow; Lock Contention Is' by Preshing on Programming provides benchmarks that perfectly illustrate this point.

Read the introduction and the 'Lock Contention Benchmark' section. The key is to understand the graph: it shows performance scaling with multiple threads, but only when the 'lock duration' (the percentage of time the lock is held) is very low. As contention increases, the benefits of multithreading vanish.

Key Insights from the Benchmark

As the article demonstrates, performance is a direct function of contention.

  • Low Contention (e.g., lock held < 10% of the time): You get excellent scaling. Adding more threads makes the program faster.
  • High Contention (e.g., lock held > 50% of the time): The threads spend most of their time waiting. Parallel execution becomes serialized. Adding more threads can even make the program slower due to the overhead of context switching managed by the OS.
Performance Comparison: With vs. Without Cache Contention
This chart visually summarizes the concept. The time taken for operations 'With Cache Contention' (analogous to lock contention) grows exponentially compared to operations without contention, showing how performance degrades under competition for a shared resource.

For your telemetry profiler, imagine thousands of events being generated per second from a dozen different threads. If each event capture requires acquiring a lock on a single, global queue, your application threads will spend most of their time stalled, waiting for that lock. This is the observer effect in its most direct form.

Test your understanding!

In our producer-consumer queue from the previous lesson, what specific workload would cause the most severe lock contention? Consider the number of producers/consumers and the time it takes to produce/consume an item.

Show answer

Severe lock contention would occur in a scenario with many fast producers and one slow consumer. The many producer threads would constantly be fighting each other for the lock to push items into the queue. Because the consumer is slow, the queue would likely fill up, causing the producers to also block on the not_full condition variable, further complicating the contention scenario.


False Sharing: The Invisible Performance Thief

While lock contention is a logical bottleneck you can often spot in your code's structure, false sharing is a much more insidious problem rooted in the hardware architecture of modern CPUs. To understand it, we first need a quick refresher on CPU caches.

Cache-Friendly C++ - Jonathan Müller - CppCon 2025

The video 'Cache-Friendly C++' provides a concise explanation of CPU caches and, crucially, the concept of a 'cache line'. False sharing is impossible to understand without first grasping what a cache line is.

Watch these two segments: 04:12 - 07:10: This explains the cache hierarchy (L1, L2, L3) and why main memory is so slow in comparison. 23:42 - 26:10: This part introduces the cache line, the smallest unit of data transferred between the cache and main memory (typically 64 bytes). This is the most important concept for this section.

So, the key takeaway is that CPUs don't move data byte by byte. They move it in 64-byte chunks called cache lines.

False sharing occurs when:

  1. Multiple threads on different cores access independent variables.
  2. Those variables happen to be located on the same cache line in memory.
  3. At least one thread writes to its variable.

When a thread on Core 1 writes to its variable, the CPU's cache coherence protocol must invalidate that entire cache line for all other cores. If a thread on Core 2 needs to access its own variable on that same cache line, it results in a cache miss. It must fetch the updated line from Core 1 or main memory. This back-and-forth invalidation traffic between cores is extremely slow, even though the threads are not sharing any data from a programming perspective!

False Sharing Illustration
This diagram shows the problem perfectly. Thread 1 (Core 1) and Thread 2 (Core 2) are working on different data (green and blue blocks). However, because both blocks of data reside on the same cache line in main memory, a write by one thread will force an invalidation and expensive reload for the other.

This is a huge problem in high-performance computing because it looks like you're accessing local data, but the performance is as if you're fighting over a lock.

Let's see a practical example of how this happens and how to fix it.

Cache-Friendly C++ - Jonathan Müller - CppCon 2025

The same 'Cache-Friendly C++' video has a fantastic segment demonstrating false sharing with a benchmark.

Watch from 47:11 to 50:47. The example uses an array of counters, one for each thread. You'll see that performance fails to scale. The solution is to add 'padding' to ensure each thread's counter is on a separate cache line.

Fixing False Sharing in C++

The solution, as shown in the video, is to force the compiler to place your independent variables on different cache lines. We do this by adding padding or by explicitly controlling the memory alignment of our data structures. Modern C++ gives us a standard way to do this with alignas.

The following article gives a detailed C++ case study.

Performance Implications of False Sharing

The article 'Performance Implications of False Sharing' provides a great code-level walkthrough of diagnosing and fixing false sharing.

Read through the 'Optimization Case Study', 'Benchmarking and Profiling', and the results sections. Focus on: How four independent std::atomic<int> variables, declared one after another, end up on the same cache line. The solution: wrapping the atomic in a struct and using alignas(64) to force 64-byte alignment. The benchmark results, especially the 'L1 cache hit rate', which drops dramatically under false sharing (~40% misses) and recovers in the aligned version (~3% misses).

For your profiler, this is critically important. The core data pipeline will involve per-thread data structures (like the ring buffers we will build). If you create an array of these structures, PerThreadData buffers[NUM_THREADS];, and each PerThreadData struct is smaller than a cache line, you are creating a perfect scenario for false sharing. Thread 0 writing to its buffer would constantly invalidate the cache for Thread 1, destroying your performance. The solution will be to ensure your PerThreadData struct is aligned to a cache line boundary.

Test your understanding!

You define a simple struct to hold statistics for each thread:

struct ThreadStats {
    uint64_t events_processed; // 8 bytes
    uint64_t data_volume;      // 8 bytes
};

You then create an array for 16 threads: ThreadStats stats[16];. Each thread only ever writes to its own stats[thread_id] element. Why is this design likely to suffer from severe false sharing? How would you fix it using alignas?

Show answer

The ThreadStats struct is only 16 bytes in size (8 + 8). A typical cache line is 64 bytes. This means that four ThreadStats objects will fit onto a single cache line (16 * 4 = 64).

When stats[0] is modified by thread 0, it invalidates the cache line for threads 1, 2, and 3. When stats[1] is modified by thread 1, it invalidates the line for threads 0, 2, and 3, and so on. They are constantly fighting over the same cache line.

The fix is to align the struct to 64 bytes. This forces each element of the array to start on a new cache line, guaranteeing they are isolated from each other at the hardware level.

// Get the cache line size from the implementation if available. 64 is a safe bet.
constexpr size_t CACHE_LINE_SIZE = 64; 

struct alignas(CACHE_LINE_SIZE) ThreadStats {
    uint64_t events_processed;
    uint64_t data_volume;
    // The compiler will add padding here to make the total size 64 bytes.
};

// Now, each element in this array is guaranteed to be on its own cache line.
ThreadStats stats[16]; 

Conclusion

In this lesson, you've explored two critical performance limiters in concurrent programming that go beyond simple correctness.

Key Takeaways:

  • Lock Contention is a logical bottleneck where threads serialize their execution waiting to acquire the same lock. Performance degrades as lock frequency and duration increase.
  • False Sharing is a subtle hardware bottleneck where threads unintentionally invalidate each other's caches by writing to independent variables that happen to occupy the same cache line.
  • The solution to contention is to reduce lock hold time or, preferably, design algorithms that avoid sharing the lock altogether.
  • The solution to false sharing is to align your data structures to cache line boundaries using alignas or manual padding, ensuring that data accessed by different threads lives on different cache lines.

These two problems are the primary motivation for moving beyond simple lock-based data structures. They explain why achieving true zero-overhead instrumentation is so challenging and why we will dedicate significant time to building lock-free alternatives.

In our next lesson, we will discuss a C++ feature that provides a very convenient way to avoid these problems in certain cases: thread-local storage. It allows you to create variables where each thread automatically gets its own independent copy, elegantly sidestepping the issues of both contention and false sharing.