What Is a Lock-Free Stack?
A lock-free stack is a concurrent data structure that allows multiple threads to push and pop elements without using traditional synchronization primitives like mutexes or semaphores. Instead, it relies on atomic operations—most notably compare-and-swap (CAS)—to achieve thread safety. The canonical implementation is the Treiber Stack, introduced by R. Kent Treiber in 1986. At its heart lies a singly linked list where the top pointer is atomically updated on every push or pop.
The term "lock-free" has a precise meaning in concurrency theory: at least one thread is guaranteed to make progress in a finite number of steps, even if other threads are suspended or delayed. This is stronger than "obstruction-free" (a thread makes progress if it runs in isolation) and weaker than "wait-free" (every thread makes progress in a bounded number of steps). Lock-free stacks sit in the sweet spot—they deliver high performance under contention without the complexity of wait-free designs.
Why Lock-Free Stacks Matter in Interviews
🚀 Deploy your AI agent in 10 minutes
Managed Hermes hosting. Zero DevOps. 100M tokens/mo included.
Try it free →Senior systems engineering interviews at companies like Google, Meta, Jump Trading, and Citadel frequently feature lock-free data structure questions. A lock-free stack is the classic "first test" because:
- It exposes understanding of memory ordering. You must reason about acquire/release semantics on the top pointer.
- It reveals knowledge of the ABA problem. Interviewers expect you to identify and solve this immediately.
- It tests memory reclamation strategies. How do you safely delete a node that may still be referenced by a lagging thread?
- It separates theorists from practitioners. Many candidates can describe CAS; far fewer can write a correct, complete implementation under pressure.
Mastering the lock-free stack gives you a framework to tackle harder problems like lock-free queues (Michael & Scott), concurrent hash tables, and epoch-based memory managers. The patterns—tagged pointers, hazard pointers, epoch reclamation—recur across all lock-free data structures.
The Core Challenges
Challenge 1: The ABA Problem
The ABA problem is the single most famous pitfall in lock-free programming. It arises because CAS compares memory values, not identities. Consider this sequence:
- Thread A reads the top pointer and sees Node X.
- Thread B pops Node X, pushes a new Node Y, then pushes Node X back (X is reused from a free list, for instance).
- Thread A's CAS succeeds because the top pointer looks like Node X—but the stack has changed beneath it.
The CAS cannot distinguish between "the original Node X" and "a recycled node that happens to occupy the same address." This corrupts the stack. The solution is to attach a generation counter or tag to the pointer, creating a tagged pointer (also called a "fat pointer"). On every modification, the counter is incremented, making the pointer value unique across reuses.
Challenge 2: Memory Reclamation
In a lock-free stack, you cannot simply delete a popped node. Why? Because after popping, another thread may still hold a reference to that node and is about to dereference it. Premature deletion causes a use-after-free. Solutions fall into three families:
- Tagged pointers with deferred deletion — suitable only if you can prove no lingering references exist (e.g., in a single-consumer scenario).
- Hazard Pointers (HP) — each thread announces which node it is currently accessing. Before deletion, you check if any thread has "hazarded" that node.
- Epoch-Based Reclamation (EBR) — threads operate in epochs. Objects are marked for deletion and only freed when all active threads have moved to a later epoch.
Challenge 3: Memory Ordering
On modern architectures (x86, ARM, RISC-V), memory operations can be reordered by both the compiler and the CPU. Lock-free stacks demand precise fences. For push, you need a release store so the new node's contents are visible before the top pointer is updated. For pop, you need an acquire load so you see the previous push's data. Using memory_order_relaxed where you should use memory_order_release leads to subtle bugs that only manifest under stress on ARM chips.
Implementing a Lock-Free Stack
Basic Treiber Stack (Unsafe Deletion)
Let's build the simplest version first. This code is not production-ready because it ignores the ABA problem and memory reclamation, but it illustrates the core CAS loop.
#include <atomic>
#include <memory>
template <typename T>
class LockFreeStack {
struct Node {
T data;
Node* next;
Node(T val) : data(std::move(val)), next(nullptr) {}
};
std::atomic<Node*> top_{nullptr};
public:
void push(T val) {
Node* new_node = new Node(std::move(val));
new_node->next = top_.load(std::memory_order_acquire);
// CAS loop: try to swing top_ to new_node
while (!top_.compare_exchange_weak(
new_node->next, // expected: current top
new_node, // desired: new node
std::memory_order_release,
std::memory_order_relaxed)) {
// If CAS fails, new_node->next is reloaded with current top_
// Just retry
}
}
std::optional<T> pop() {
Node* old_top = top_.load(std::memory_order_acquire);
while (old_top != nullptr) {
Node* next = old_top->next;
if (top_.compare_exchange_weak(
old_top, // expected
next, // desired
std::memory_order_acquire,
std::memory_order_relaxed)) {
// Successfully unlinked old_top
T result = std::move(old_top->data);
delete old_top; // DANGEROUS: ABA + use-after-free!
return result;
}
// CAS failed; old_top is reloaded, retry
}
return std::nullopt; // stack empty
}
};
This works correctly only if no nodes are ever reused and no thread holds a stale reference after a pop. In practice, both conditions break immediately under contention. The delete old_top is the ticking time bomb: a thread that just entered pop() with old_top pointing to this node will crash.
Tagged Pointer Solution for ABA
We solve ABA by embedding a counter alongside the pointer. On 64-bit systems, we exploit the fact that addresses typically use only 48 bits (or 52 bits with 5-level paging). The top 16 bits can hold a tag. We pack and unpack using bitwise operations.
#include <atomic>
#include <cstdint>
#include <bit>
// On x86-64 with 48-bit virtual addresses, we can steal 16 bits for the tag.
// Adjust TAG_SHIFT if your platform uses more bits (e.g., 52-bit addresses).
constexpr uintptr_t TAG_MASK = 0xFFFF'FFFF'FFFFULL; // lower 48 bits for pointer
constexpr uintptr_t TAG_SHIFT = 48;
constexpr int TAG_INCR = 1;
struct TaggedPointer {
uintptr_t packed; // bits 0-47: pointer, bits 48-63: tag
static TaggedPointer make(Node* ptr, uint16_t tag) {
return TaggedPointer{
(reinterpret_cast<uintptr_t>(ptr) & TAG_MASK) |
(static_cast<uintptr_t>(tag) << TAG_SHIFT)
};
}
Node* ptr() const {
return reinterpret_cast<Node*>(packed & TAG_MASK);
}
uint16_t tag() const {
return static_cast<uint16_t>(packed >> TAG_SHIFT);
}
};
template <typename T>
class TaggedLockFreeStack {
struct Node {
T data;
TaggedPointer next; // points to next node with tag
Node(T val) : data(std::move(val)), next(TaggedPointer{0}) {}
};
// Atomic tagged pointer for the top of stack
std::atomic<uintptr_t> top_packed_{0};
TaggedPointer load_top(std::memory_order order) const {
return TaggedPointer{top_packed_.load(order)};
}
public:
void push(T val) {
Node* new_node = new Node(std::move(val));
TaggedPointer old_top = load_top(std::memory_order_acquire);
// Set new_node->next to point to current top (with its tag)
new_node->next = old_top;
// New tag = old tag + 1 (or any unique increment)
TaggedPointer new_top = TaggedPointer::make(
new_node, old_top.tag() + TAG_INCR);
uintptr_t expected = old_top.packed;
// CAS loop
while (!top_packed_.compare_exchange_weak(
expected,
new_top.packed,
std::memory_order_release,
std::memory_order_relaxed)) {
// Reload old_top from the failed expected value
old_top = TaggedPointer{expected};
new_node->next = old_top;
new_top = TaggedPointer::make(new_node, old_top.tag() + TAG_INCR);
}
}
std::optional<T> pop() {
TaggedPointer old_top = load_top(std::memory_order_acquire);
while (old_top.ptr() != nullptr) {
Node* node = old_top.ptr();
TaggedPointer next = node->next; // read next with its tag
TaggedPointer new_top = TaggedPointer::make(
next.ptr(), old_top.tag() + TAG_INCR);
uintptr_t expected = old_top.packed;
if (top_packed_.compare_exchange_weak(
expected,
new_top.packed,
std::memory_order_acquire,
std::memory_order_relaxed)) {
T result = std::move(node->data);
// STILL UNSAFE: need hazard pointers or EBR for deletion
// For now: leak or use a deferred reclamation scheme
delete node;
return result;
}
old_top = TaggedPointer{expected};
}
return std::nullopt;
}
};
Notice the tagged pointer eliminates ABA because the tag changes on every push/pop. Even if a node is recycled at the same address, the tag differs, so the CAS fails. But we still have the reclamation problem—that delete node is unsafe under contention.
Hazard Pointers for Safe Deletion
Hazard pointers were introduced by Maged Michael in 2004. The idea: before accessing a shared node, a thread publishes a "hazard pointer" declaring "I am currently using this node." A reclaimer thread scans all hazard pointers and only frees nodes that no thread has hazarded.
#include <atomic>
#include <vector>
#include <thread>
#include <algorithm>
// Global hazard pointer array: one slot per thread (pre-allocated)
constexpr int MAX_THREADS = 128;
std::atomic<Node*> hazard_pointers[MAX_THREADS];
// Each thread gets a unique slot index at startup
thread_local int my_hp_slot = []() {
static std::atomic<int> next_slot{0};
return next_slot.fetch_add(1);
}();
// Announce that we are accessing a node
void set_hazard(Node* node) {
hazard_pointers[my_hp_slot].store(node, std::memory_order_release);
}
// Clear our hazard pointer
void clear_hazard() {
hazard_pointers[my_hp_slot].store(nullptr, std::memory_order_release);
}
// Check if any thread has hazarded a given node
bool is_hazarded(Node* node) {
for (int i = 0; i < MAX_THREADS; ++i) {
if (hazard_pointers[i].load(std::memory_order_acquire) == node) {
return true;
}
}
return false;
}
// Retire a node: add to a thread-local retire list, batch-free when safe
thread_local std::vector<Node*> retire_list;
constexpr int RETIRE_THRESHOLD = 64;
void retire_node(Node* node) {
retire_list.push_back(node);
if (retire_list.size() >= RETIRE_THRESHOLD) {
// Attempt to free nodes that no thread has hazarded
std::vector<Node*> survivors;
for (Node* n : retire_list) {
if (is_hazarded(n)) {
survivors.push_back(n); // still in use, keep it
} else {
delete n; // safe to free
}
}
retire_list = std::move(survivors);
}
}
Now integrate hazard pointers into the pop operation:
std::optional<T> pop_safe() {
TaggedPointer old_top = load_top(std::memory_order_acquire);
while (old_top.ptr() != nullptr) {
Node* node = old_top.ptr();
// Publish hazard pointer BEFORE dereferencing node->next
set_hazard(node);
// Fence to ensure hazard pointer is visible before we read node->next
std::atomic_thread_fence(std::memory_order_seq_cst);
// Re-read top after publishing hazard (defense against race)
TaggedPointer current_top = load_top(std::memory_order_acquire);
if (current_top.packed != old_top.packed) {
// Top changed; retry from the new top
clear_hazard();
old_top = current_top;
continue;
}
TaggedPointer next = node->next;
TaggedPointer new_top = TaggedPointer::make(
next.ptr(), old_top.tag() + TAG_INCR);
uintptr_t expected = old_top.packed;
if (top_packed_.compare_exchange_weak(
expected,
new_top.packed,
std::memory_order_acquire,
std::memory_order_relaxed)) {
T result = std::move(node->data);
// Node is unlinked; clear hazard and retire for deferred deletion
clear_hazard();
retire_node(node);
return result;
}
// CAS failed; clear hazard, update old_top, retry
clear_hazard();
old_top = TaggedPointer{expected};
}
clear_hazard();
return std::nullopt;
}
The critical sequence is: hazard → fence → re-validate → dereference. The fence prevents the CPU from reordering the hazard store after the node->next load. Without it, another thread could see an empty hazard pointer while we're already reading through the node. The re-validation (current_top check) closes a tiny window where the node could have been popped and freed between our initial load and the hazard publication.
Epoch-Based Reclamation (EBR) — The High-Performance Alternative
Hazard pointers require a store and fence on every access. EBR amortizes this cost. Threads operate in a global epoch (0, 1, 2...). A thread announces its current epoch. Objects are tagged with the epoch when they were retired. When all active threads have advanced past a given epoch, objects from that epoch can be freed. The overhead per operation is just a few loads and stores—no expensive fences.
// Simplified EBR sketch (full implementation requires careful state machine)
class EpochManager {
std::atomic<uint64_t> global_epoch_{0};
std::atomic<int> active_threads_in_epoch_[3] = {};
public:
uint64_t enter_epoch() {
// Announce we are active in the current epoch
uint64_t e = global_epoch_.load(std::memory_order_acquire);
active_threads_in_epoch_[e % 3].fetch_add(1, std::memory_order_relaxed);
// Ensure our epoch announcement is visible
std::atomic_thread_fence(std::memory_order_seq_cst);
// Re-read: if epoch changed, we may need to adjust
return global_epoch_.load(std::memory_order_relaxed);
}
void leave_epoch(uint64_t epoch) {
active_threads_in_epoch_[epoch % 3].fetch_sub(1, std::memory_order_release);
}
bool can_free(uint64_t retired_epoch) {
// An object can be freed if its retirement epoch is two epochs behind
// the current global epoch AND no threads remain in that epoch.
uint64_t current = global_epoch_.load(std::memory_order_acquire);
if (current - retired_epoch < 2) return false;
return active_threads_in_epoch_[retired_epoch % 3].load(std::memory_order_acquire) == 0;
}
void try_advance_epoch() {
// If the current epoch has no active threads, advance global_epoch_
uint64_t e = global_epoch_.load(std::memory_order_relaxed);
if (active_threads_in_epoch_[e % 3].load(std::memory_order_acquire) == 0) {
global_epoch_.compare_exchange_strong(e, e + 1, std::memory_order_release);
}
}
};
In practice, production EBR implementations (like folly::RCU or libcds) combine epochs with grace periods and batch deletion for extremely high throughput. The key insight for interviews: you don't need to implement a full EBR system from scratch, but you should be able to explain the trade-offs—EBR gives higher throughput but can cause memory to accumulate if a thread stalls in an epoch indefinitely, whereas hazard pointers are more predictable but have higher per-operation overhead.
Common Interview Questions & Solutions
Q1: "Implement a lock-free stack from scratch."
What they're testing: Can you write a correct CAS loop under pressure? Do you naturally reach for compare_exchange_weak? Do you use proper memory ordering?
Solution approach: Start with the Treiber stack skeleton. Write the Node struct, the atomic top, and the push/pop CAS loops. Use compare_exchange_weak in a loop (it may spuriously fail on some architectures, which is fine because you retry). Explicitly state your memory order choices. Then say: "This basic version has two problems—ABA and safe deletion. Let me address them."
Q2: "Explain the ABA problem and how you'd solve it."
What they're testing: Do you truly understand CAS semantics, or did you just memorize the pattern?
Solution approach: Give the concrete three-step example (Thread A reads X, Thread B pops/pushes/reuses X, Thread A's CAS succeeds incorrectly). Then explain tagged pointers: "We pack a monotonic counter into the unused bits of the pointer. Every push increments the counter, so even if the same address reappears, the CAS fails because the packed value differs." Show the bit layout and mention platform-specific constraints (48-bit addresses on x86-64).
Q3: "How do you safely delete nodes in a lock-free stack?"
What they're testing: Knowledge of the memory reclamation taxonomy and practical experience with hazard pointers or EBR.
Solution approach: Enumerate the options. "There are three standard approaches: hazard pointers, epoch-based reclamation, and reference counting with atomic shared_ptr. Hazard pointers are the simplest to explain—each thread publishes which node it's currently accessing. Before freeing a node, we scan all hazard pointers. EBR is faster but more complex, grouping deletions by epoch. For an interview, I'd implement hazard pointers." Then sketch the hazard pointer integration into pop.
Q4: "What memory order do you use and why?"
What they're testing: Understanding of the C++ memory model, not just cargo-culting memory_order_seq_cst.
Solution approach: "For push: the store to new_node->next must be visible before we publish new_node via CAS, so the CAS uses memory_order_release. For pop: the load of top_ must synchronize with that release to see the full node chain, so we use memory_order_acquire. The failure path in CAS can be memory_order_relaxed because we just retry. I only use seq_cst fences in the hazard pointer integration to prevent reordering of the hazard store and the node dereference."
Q5: "What happens if a thread stalls while holding a hazard pointer?"
What they're testing: Understanding of liveness vs. safety trade-offs.
Solution approach: "Hazard pointers are lock-free but not wait-free for reclamation. If a thread stalls (e.g., descheduled by the OS) while holding a hazard pointer, the node it references cannot be freed. This causes retire list inflation—the reclaimer accumulates garbage. However, the stack operations themselves remain lock-free; other threads can still push and pop. The stalled thread does not block progress, it only delays memory reclamation. EBR has a similar issue: a stalled thread in an old epoch prevents freeing all objects retired in that epoch. This is why production systems combine reclamation with timeouts or fallback mechanisms."
Best Practices
- Use
compare_exchange_weakin loops, notcompare_exchange_strong. The weak version may spuriously fail on LL/SC architectures (ARM, PowerPC) but generates tighter code. In a retry loop, spurious failures cost nothing. - Always validate after publishing a hazard pointer. The hazard→fence→revalidate pattern is non-negotiable. Skipping the revalidation creates a tiny but real use-after-free window.
- Batch your deletions. Don't scan the full hazard array for every single
delete. Accumulate retired nodes in a thread-local list and process in batches (e.g., every 64 retirements). This amortizes the O(N_threads) scan cost. - Benchmark with contention. A lock-free stack that shines in single-threaded microbenchmarks may collapse under 16-thread contention. Use real stress tests—rapid push/pop from many threads, with
std::atomic<bool>flags to detect corruption. - Consider the platform. x86-64 TSO (Total Store Order) is forgiving; ARM and RISC-V are not. Code that works on x86 may fail spectacularly on ARM. Always test on a weakly-ordered architecture or use
std::atomic_thread_fenceappropriately. - Don't reinvent EBR for interviews unless asked. Hazard pointers are sufficient to demonstrate understanding. Save the full epoch state machine for when the interviewer probes deeper.
- Document your assumptions. In an interview, explicitly state: "I'm assuming 48-bit virtual addresses for the tagged pointer" or "I'm allocating a fixed-size hazard pointer array—in production you'd use a dynamic registry." This shows engineering maturity.
- Prefer tagged pointers over double-word CAS. Some solutions use
DWCAS(compare-and-swap on two adjacent words) to atomically update both pointer and counter. Tagged pointers packed into a single word are simpler, faster, and universally supported on 64-bit architectures.
Conclusion
The lock-free stack is a microcosm of concurrent systems engineering. In a compact, interview-friendly package, it touches atomic operations, memory ordering, the ABA problem, memory reclamation strategies, and the delicate balance between performance and correctness. Walking through a complete implementation—from the naive Treiber stack to a tagged-pointer, hazard-pointer-equipped version—demonstrates not just knowledge of lock-free programming but the judgment to know which problems matter and how to solve them systematically. Whether you're preparing for a systems interview or building a high-throughput trading engine, the patterns you internalize here—CAS loops, tagged pointers, deferred reclamation—will recur across every lock-free data structure you encounter. Master the lock-free stack, and you've mastered the foundation.