Skip to main content

Welcome to a Grasp lesson

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

Lesson 18

Optimizing with Atomics and Per-CPU Variables

Recommended resources

Introduction

Welcome back! In our previous lesson, we explored Read-Copy-Update (RCU), a highly scalable synchronization mechanism optimized for read-mostly workloads. You learned how RCU allows readers to proceed without any locking, achieving impressive performance by having writers create copies and relying on a grace period system for safe memory reclamation.

Today, we will delve deeper into the toolbox for avoiding locking overhead in performance-critical paths. The learning outcome for this lesson is to use atomic operations and per-CPU variables to avoid locking overhead in performance-critical paths. These are fundamental techniques for achieving scalability in the Linux kernel, used in situations where even the minimal overhead of a spinlock is unacceptable.

We will cover:

  • What atomic operations are and how they are guaranteed by hardware.
  • The trade-offs of using atomics, including the critical performance pitfall of cache contention.
  • The workhorse of lock-free algorithms: Compare-and-Swap (CAS).
  • The importance of memory ordering to ensure correctness.
  • The ultimate lock-avoidance strategy: eliminating sharing with per-CPU variables.

1. Atomic Operations: The Foundation

In multithreaded programming, a simple operation like i++ is not a single, indivisible step. It's a sequence: read i, modify the value, and write it back. As you know, this creates a race condition. While locks solve this, they introduce overhead. Atomic operations provide a way to perform these simple read-modify-write (RMW) sequences as a single, indivisible instruction, guaranteed by the hardware itself.

To build a strong intuition for what atomics are and why they're needed, let's start with a video that explains the core concepts. Although it uses C++ syntax, the underlying hardware principles are identical to what the Linux kernel relies on.

CppCon 2017: Fedor Pikus “C++ atomics, from basic to advanced. What do they really do?”

This segment from 'C++ atomics, from basic to advanced' by Fedor Pikus explains what atomic operations are, how data races occur at a hardware level due to CPU caches, and how atomics solve this.

Watch from 04:23 to 10:34. Pay close attention to: The definition of an atomic operation as a single transaction. The illustration of a data race with a simple increment (i++). The role CPU caches play in causing inconsistent views of memory. How wrapping a variable in an atomic type (conceptually similar to the kernel's atomic_t) resolves this by using special hardware instructions.

In the Linux kernel, the primary type for this is atomic_t, defined in <linux/atomic.h>. It's a 32-bit integer, and you should only ever manipulate it using the dedicated atomic_* API functions, such as:

  • atomic_read(v): Atomically reads the value of v.
  • atomic_set(v, i): Atomically sets v to i.
  • atomic_inc(v): Atomically increments v.
  • atomic_dec(v): Atomically decrements v.
  • atomic_add(i, v): Atomically adds i to v.
  • atomic_dec_and_test(v): Atomically decrements v and returns true if the result is 0.

These operations are typically compiled down to single, hardware-supported atomic instructions, making them much faster than acquiring and releasing a spinlock.


2. The Performance Pitfall: Contention and False Sharing

You might assume that replacing a lock with an atomic operation always improves performance. However, this is a dangerous assumption. Atomic operations, while "lock-free," are not "cost-free." When multiple CPUs frequently try to update the same atomic variable, they contend for ownership of the cache line where that variable resides. This constant shuffling of the cache line between CPU caches is known as cache bouncing or cache line contention, and it can severely degrade performance.

This effect is so significant that a naive lock-free algorithm can be much slower than a well-designed lock-based one.

CppCon 2017: Fedor Pikus “C++ atomics, from basic to advanced. What do they really do?”

Let's return to Fedor Pikus's talk. The very beginning and a later segment perfectly illustrate the performance cost of cache contention and a related issue, false sharing.

First, watch the opening segment from 00:55 to 04:15. Notice how the 'wait-free' program using a single atomic sum scales poorly, while the lock-based version using a local accumulator scales well. This is a direct result of cache contention. \nThen, watch from 30:19 to 32:58. This part explains the mechanics of cache line sharing and false sharing—where two logically separate variables suffer from contention because they happen to reside on the same cache line.

The key insight here is crucial for high-performance design: the cost is not in the atomic instruction itself, but in the memory system synchronization it triggers. If you have multiple CPUs hammering on the same cache line, performance will collapse, regardless of whether you're using locks or atomics. This leads us to the two primary strategies for high-performance code:

  1. Design clever algorithms that minimize shared writes (as seen in the lock-based local accumulator example).
  2. Eliminate sharing altogether (which we will cover with per-CPU variables).

3. The Atomic Workhorse: Compare-and-Swap (CAS)

Simple atomic increments are useful, but many lock-free algorithms require a more powerful primitive: Compare-and-Swap (CAS). In the kernel, the primary CAS primitive is cmpxchg (compare-and-exchange).

The logic of CAS is: "Update the value at memory location P to new only if its current value is old." It atomically performs this check and update, returning the value that was actually at P just before the operation.

int cmpxchg(atomic_t *ptr, int old, int new);

This allows for constructing complex atomic updates via a "retry loop":

  1. Atomically read the current value.
  2. Perform complex, non-atomic calculations to compute a new value.
  3. Use CAS to try and swap the original value with your new one.
  4. If the CAS fails, it means another thread modified the value in the meantime. The CAS operation helpfully returns the new current value, so you loop and try again with the updated information.

CppCon 2017: Fedor Pikus “C++ atomics, from basic to advanced. What do they really do?”

This part of the CppCon video explains the logic of Compare-and-Swap and demonstrates its use in a typical retry loop.

Watch from 15:43 to 18:49. Focus on understanding how the CAS operation (called 'compare-exchange' in C++) works and why the retry loop is fundamental to most lock-free algorithms.

CAS is the engine behind many sophisticated lock-free data structures, like queues. For example, a lock-free queue might use a 128-bit CAS (cmpxchg16b on x86-64) to atomically update both a pointer and a version counter, which cleverly avoids the ABA problem. The article "On the Complexity of Synchronization" provides more details on the hardware primitives.

On the Complexity of Synchronization

This article discusses the kernel-specific view of CAS and the underlying hardware instructions.

Read the sections 'Compare-and-swap (CAS)' and the subsections under '0x86' that cover CMPXCHG and LOCK CMPXCHG. This will connect the high-level concept to the low-level x86 instructions the kernel uses.


4. Ensuring Correctness: Memory Ordering

Atomic operations by themselves only guarantee atomicity. They do not, by default, prevent the compiler or the CPU from reordering memory accesses around them. This can lead to subtle and catastrophic bugs in lock-free code.

Consider this classic producer-consumer problem:

// CPU 1 (Producer)
data = 42;
data_ready = 1;

// CPU 2 (Consumer)
if (data_ready == 1) {
    print(data); // Could print garbage!
}

The CPU or compiler is free to reorder the writes on CPU 1, so data_ready might become 1 before data is set to 42. CPU 2 would then read the stale value of data.

To solve this, we need memory barriers. These are instructions that enforce an ordering on memory operations. The modern way to handle this is with acquire and release semantics.

  • Store-Release: When you write to a variable with release semantics (e.g., setting the data_ready flag), you guarantee that all memory writes before this store are visible to any CPU that reads it with acquire semantics.
  • Load-Acquire: When you read a variable with acquire semantics, you guarantee that if you see the new value, you will also see all memory writes that happened before the corresponding store-release.

On the Complexity of Synchronization

The article 'On the Complexity of Synchronization' provides an excellent, kernel-centric explanation of lock-free programming using acquire and release semantics.

Read the section titled 'Lock-free Programming'. Focus on the example provided and how smp_store_release and smp_load_acquire are used to fix the data race. This establishes the 'happens-before' relationship that is critical for correctness.

The corrected example looks like this:

// CPU 1 (Producer)
data = 42;
smp_store_release(&data_ready, 1);

// CPU 2 (Consumer)
if (smp_load_acquire(&data_ready) == 1) {
    print(data); // Guaranteed to print 42
}

Many kernel atomic operations have variants that include these memory barriers, such as atomic_add_return_release. Using the weakest memory ordering necessary for your algorithm (_relaxed, _acquire, _release) is key to maximizing performance, as stronger ordering (like the default sequential consistency) can be expensive.


5. Eliminating Sharing: Per-CPU Variables

While atomics reduce locking overhead, they don't eliminate the fundamental problem of contention on shared data. The most effective way to solve this is to eliminate the sharing altogether. This is the purpose of per-CPU variables.

A per-CPU variable is an instance of a data structure that is duplicated for every CPU in the system. When code on CPU 0 accesses the variable, it gets its own private copy. When code on CPU 1 accesses it, it gets a different private copy.

Linux Kernel Per-CPU Variable Memory Layout
This diagram illustrates the memory layout of per-CPU variables. A single logical variable in code is expanded into an array of instances, one for each CPU. The kernel uses an offset (`_per_cpu_offset`) to find the correct instance for the currently executing CPU, ensuring data isolation.

This approach offers tremendous performance benefits:

  • No Locks Needed: Since a CPU is only ever modifying its own copy, there is no contention with other CPUs. No locks or atomic instructions are needed for local updates.
  • No Cache Contention: Each CPU's copy resides in a different memory location (often aligned to prevent false sharing), so there is no cache line bouncing. Writes are extremely fast.

This technique is ideal for things like statistics counters, where each CPU can update its local count at high speed, and the relatively rare operation of getting a global total can be done by iterating through all CPUs and summing their local values.

this_cpu operations - The Linux Kernel documentation

The official kernel documentation provides the best explanation of how to use per-CPU variables and the highly optimized this_cpu API.

Read the introduction and the sections 'Inner working of this_cpu operations' and 'Special operations'. Focus on: The core idea: avoiding atomicity issues by giving each processor its own data. How this_cpu operations use special CPU features (like segment registers on x86) to create a single, highly-efficient instruction for RMW operations. The distinction between a per-CPU variable and a per-CPU pointer (this_cpu_ptr).

Here's how you use them in practice:

  1. Declaration: Use the DEFINE_PER_CPU(type, name) macro to create a per-CPU variable.
  2. Access: Use the this_cpu_* operations for safe, efficient access from the local CPU. These operations implicitly handle preemption and provide the fastest possible access.
    • this_cpu_inc(my_per_cpu_counter);
    • this_cpu_add(my_per_cpu_counter, 5);
    • val = this_cpu_read(my_per_cpu_counter);
  3. Aggregation: To get the global sum, you must manually loop through all CPUs.
    long total = 0;
    int cpu;
    for_each_possible_cpu(cpu) {
        total += per_cpu(my_per_cpu_counter, cpu);
    }
    

It is critical to remember that writing to another CPU's data remotely is highly discouraged. It defeats the purpose of per-CPU variables by re-introducing cache synchronization issues and can interfere with the lockless this_cpu operations. If you need one CPU to modify another's data, the proper way is to use an Inter-Processor Interrupt (IPI) to send a message asking the remote CPU to perform the update itself.


6. Interview Scenario: Designing a Scalable Counter

Let's apply these concepts to a typical interview problem.

Scenario: You are asked to implement a counter to track the total number of packets processed by a network driver across a 128-core system. This counter will be updated millions of times per second. How would you implement this for maximum performance and scalability?

Analysis:

  1. A Global Lock (Spinlock/Mutex): This would be a disaster. With 128 cores trying to acquire the same lock, the system would spend most of its time spinning or waiting. This is a massive scalability bottleneck.

  2. A Global atomic_t: This is better than a lock, as it avoids the sleep/spin overhead of the lock itself. However, it creates a single point of contention in memory. All 128 cores would be fighting for the same cache line, leading to severe cache bouncing. As we saw in the video, performance would not scale and might even decrease as you add more cores.

  3. A Per-CPU Variable: This is the correct answer for this scenario.

    • Implementation: You would use DEFINE_PER_CPU(unsigned long, packet_count). In the driver's packet processing path, each CPU would simply call this_cpu_inc(packet_count).
    • Why it's better: This write operation is extremely fast. It's often a single, non-locking instruction that modifies a local memory location. There is no cross-CPU traffic or contention.
    • The Trade-off: To display the total packet count (e.g., via a /proc or sysfs file), you would need to implement the read path with a for_each_possible_cpu loop to sum up the values from each CPU's private counter. This read is much slower than a single atomic_read, but since reads are infrequent compared to the millions of writes per second, this is an excellent trade-off for overall system performance.

Conclusion

In this lesson, you've learned two powerful techniques for writing highly concurrent and scalable kernel code that avoids the overhead of traditional locks.

Key Takeaways:

  • Atomic operations (atomic_t) use hardware support for indivisible read-modify-write operations on single variables. They are essential for fine-grained synchronization but can become a bottleneck under high contention due to cache line bouncing.
  • Compare-and-Swap (CAS) is a fundamental atomic primitive that allows you to build complex, lock-free algorithms using a retry-loop pattern.
  • Memory ordering (acquire/release semantics) is non-negotiable for ensuring the correctness of lock-free code by preventing harmful compiler and CPU instruction reordering.
  • Per-CPU variables offer the ultimate performance for write-heavy workloads by eliminating data sharing entirely. Each CPU updates its own private copy, avoiding all forms of contention. The trade-off is a more complex and slower read path to aggregate the data.

Understanding when to use a lock, when to use an atomic, and when to redesign for per-CPU data is a hallmark of an expert kernel developer.

In our next lesson, we will shift our focus from writing correct synchronization code to debugging incorrect implementations. You will learn how to diagnose a deadlock scenario in kernel code using the lockdep tool to analyze lock dependencies.