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
189: Lock-Free Data Structures Basics
When you first start dipping your toes into lock-free programming, it's easy to fall into the trap of thinking that simply wrapping a variable in std::atomic makes your entire data structure thread-safe. It doesn't. Atomic variables guarantee that a single read or write is indivisible, but they don't magically make a sequence of operations atomic.
Take a look at this attempt at a lock-free stack push operation. At first glance, it looks fine because we're using an atomic head pointer.
struct Node {
int data;
Node* next;
};
std::atomic<Node*> head{nullptr};
void push(int val) {
Node* newNode = new Node{val, nullptr};
// I'm using an atomic, so this should be safe, right?
newNode->next = head.load();
head.store(newNode);
}
The "Lost Update" Race Condition
If you run this in a high-concurrency environment, you'll notice nodes disappearing from your stack. This is a classic "lost update" bug. Here is exactly what's happening: Thread A loads the current head (let's say it's Node 10). Simultaneously, Thread B loads the same head (Node 10). Thread A stores its newNode as the head. Then, Thread B stores its newNode as the head.
Because Thread B had no idea Thread A just updated the head, Thread B's next pointer still points to Node 10. Node A is now completely orphaned in memory—it's not in the stack, and you've just leaked memory. The load and store were individually atomic, but the gap between them was a window for failure.
Solving it with Compare-and-Swap
To fix this, we need a way to say: "Update the head to my new node, but only if the head is still the same node I saw when I started." In C++, we do this with compare_exchange_weak (often called CAS - Compare-And-Swap).
void push(int val) {
Node* newNode = new Node{val, nullptr};
// We load the current head into a local variable
Node* currentHead = head.load();
do {
// Update our new node's next pointer to the current head
newNode->next = currentHead;
// compare_exchange_weak does the heavy lifting:
// If head == currentHead, it sets head = newNode and returns true.
// If head != currentHead, it updates currentHead to the actual current head and returns false.
} while (!head.compare_exchange_weak(currentHead, newNode));
}
I used compare_exchange_weak instead of strong here. In a loop like this, the "weak" version is generally more performant on ARM and PowerPC architectures because it's allowed to fail spuriously even if the values match. Since we're looping anyway, a spurious failure doesn't hurt us—it just means we try again.
The Nuance of Memory Ordering
You'll notice I used the default memory ordering in the example above, which is std::memory_order_seq_cst (sequentially consistent). It's the safest option, but it's also the slowest because it forces a total global ordering of operations across all CPU cores.
In a real-world high-performance system, you'd likely optimize this. For the push, we need the newNode's data to be visible to any thread that later pops it. This means the store (inside the CAS) needs release semantics, and the load needs acquire semantics. If you use memory_order_relaxed here, a different thread might see the new head pointer but see garbage data inside the Node because the CPU reordered the writes. Lock-free programming isn't just about avoiding locks; it's about managing how memory is synchronized between cores.
📋 Practical Task
Implement a Lock-Free Stack Pop Operation
Now that you've seen how to push to a Treiber Stack using compare_exchange_weak, your task is to implement the pop function. This is slightly trickier because you have to handle the empty stack case and ensure you don't suffer from a race condition where the head is changed between the time you read it and the time you try to remove it.
Requirements:
- The function should return a
std::optional<int>(or a similar mechanism) to handle cases where the stack is empty. - Use
std::atomic<Node*> head. - Implement a loop using
compare_exchange_weakto ensure that the node being popped is still the current head at the moment of removal. - Ensure you properly delete the popped node to avoid memory leaks.
struct Node {
int data;
Node* next;
};
std::atomic<Node*> head{nullptr};
// TODO: Implement this function
std::optional<int> pop() {
// Your code here
}
There are no comments for now.