Concurrent Data Structures (Thread-Safe Queue & Doubly Linked List)
In modern C++, we never guess what the compiler does. We look directly at byte offsets, CPU registers, and object ownership lifetimes.
The design stems from Tom Cargill's 1994 exception-safety analysis. If pop() returned the value by value (T pop()), the queue would have to remove the element from its internal storage and then copy/move construct the return value into the caller's stack frame. If the copy constructor of T throws an exception during this return, the element has already been popped and is irretrievably lost. Separating front() (inspect without removing) and pop() (remove without returning) guaranteed strong exception safety in single-threaded C++.
By passing value as an in-out reference parameter (bool try_pop(T& value)), the assignment or move construction into the caller's target variable occurs before calling data_.pop(). If value = std::move(data_.front()) throws an exception, the element has not yet been removed from the internal queue, preserving the container's integrity and preventing data loss.
In an intrusive list, the prev and next pointers are stored directly inside the node structure that the hash map points to (LRUNode*). This eliminates the need for separate list wrapper allocations and allows O(1) removal and splicing to the front using raw pointers without having to search or re-hash keys.
Interviewer's lens
Concurrent data structures separate developers who merely know threading syntax from those who understand interface race conditions and lock granularity. In this chapter, interviewers test the famous Anthony Williams interface race: why simply slapping a mutex around individual std::queue member functions (empty(), front(), pop()) still leaves code hopelessly vulnerable to race conditions and crashes. Senior candidates excel by explaining why pop() must return values by value or optional, why std::shared_mutex reader locks fail catastrophically on LRU Cache get(), and how to build coarse vs sharded concurrent linked data structures.
Under the hood & memory layout
In single-threaded STL containers, methods are deliberately granular: std::stack::top() and std::queue::front() return a reference to the element, while pop() discards it with void return (designed to prevent exception-safety memory leaks if copying T throws).
front(), Thread 2 can pop()the element, causingfront()` to dereference an empty container.
A thread-safe data structure must redesign its public interface so that inspection and removal are combined into a single atomic critical section (try_pop or wait_and_pop).
For complex structures like an LRU Cache, combining a Doubly Linked List with a hash table requires exclusive synchronization because even read operations (get) mutate node linkage pointers.
Contains std::mutex (40 bytes on Windows, 48 bytes on Linux), std::condition_variable (48 bytes), std::queue<T> (typically backed by std::deque with 48-byte chunk-map header), and a bool stopped_ flag with alignment padding, totaling ~144–160 bytes on the stack/heap.
If Thread 1 executes if (!q.empty()) under Lock A, releases Lock A, and then acquires Lock B to call val = q.front(); q.pop();, the time window between releasing Lock A and acquiring Lock B permits Thread 2 to steal the final element, causing Thread 1 to crash or duplicate pops.
Combines a std::unordered_map<K, LRUNode*> (bucket array of pointers + node control structures) with an intrusive DoublyLinkedList where each LRUNode contains prev (8B), next (8B), key (sizeof(K)), and value (sizeof(V)). Sentinel head and tail nodes eliminate null pointer dereference branches.
A coarse mutex locks the entire data structure, creating contention on high thread counts. Lock striping (sharded locking) divides the key space into M distinct partitions (e.g., 16 or 64 buckets), each protected by its own independent mutex, allowing concurrent operations on distinct keys without contention.
Core concepts & syntax traps
The Anthony Williams Interface Race: Why Separate empty(), front(), and pop() Are Broken
The Anthony Williams Interface Race: Why Separate empty(), front(), and pop() Are Broken
A classic beginner mistake when designing concurrent data structures is taking std::queue<T> and simply wrapping every member function in a mutex:
// BROKEN THREAD-SAFE QUEUE ATTEMPT:
class NaiveQueue {
std::mutex mtx;
std::queue<int> q;
public:
bool empty() { std::lock_guard<std::mutex> lk(mtx); return q.empty(); }
int front() { std::lock_guard<std::mutex> lk(mtx); return q.front(); }
void pop() { std::lock_guard<std::mutex> lk(mtx); q.pop(); }
};
The Anatomy of the Race Condition
Consider two consumer threads executing the standard idiom on a queue with exactly 1 item remaining:
if (!q.empty()) {
int val = q.front();
q.pop();
process(val);
}
- Thread 1 calls
q.empty(). It acquiresmtx, seesq.empty() == false, releasesmtx, and enters theifblock. - Before Thread 1 can execute
q.front(), Thread 2 intervenes! Thread 2 callsq.empty()(returns false), callsq.front()(reads the element), and callsq.pop()(removes the element). - Thread 1 resumes and calls
q.front(). The queue is now empty! Callingfront()on an emptystd::queueis undefined behavior, causing an immediate crash or segmentation fault!
Even if the queue contains 2 elements, two threads calling front() independently will read the same element twice, and then calling pop() twice will skip an element entirely! Wrapping individual methods in locks does not make an interface thread-safe.
// Individual lock calls do NOT protect multi-step sequences:
// Thread A: calls empty() [LOCK -> UNLOCK]
// Thread B: calls empty() [LOCK -> UNLOCK]
// Thread A: calls front() [LOCK -> UNLOCK]
// Thread B: calls front() [LOCK -> UNLOCK] <- Reads DUPLICATE item!
// Thread A: calls pop() [LOCK -> UNLOCK]
// Thread B: calls pop() [LOCK -> UNLOCK] <- Skips an unread item!Thread safety is an interface property: checking state and extracting items must be unified into a single atomic locked call.
Designing a Race-Free Queue API: try_pop and wait_and_pop
Designing a Race-Free Queue API: try_pop and wait_and_pop
To eliminate the interface race, a concurrent queue must combine reading the element and removing it into a single locked operation. In C++, there are three canonical signatures for race-free pop operations:
1. bool try_pop(T& value)
Acquires the lock, checks if the queue is empty. If empty, releases the lock and returns false. If non-empty, moves the front element into value, pops from the queue, releases the lock, and returns true. (Exception safety note: moving into caller's reference ensures that if the move constructor throws, the item remains safely in the queue).
2. std::optional<T> try_pop()
Modern C++17/23 idiomatic non-blocking pop. Returns std::nullopt if empty, or std::optional<T>(std::move(front)) if an item was present.
3. std::optional<T> wait_and_pop()
Blocking pop for consumer threads. Puts the thread to sleep on a std::condition_variable until an item is pushed or the queue is stopped, eliminating CPU-burning polling loops.
template <typename T>
class ThreadSafeQueue {
mutable std::mutex mtx_;
std::queue<T> data_;
std::condition_variable cv_;
public:
// Race-free try_pop returning optional:
std::optional<T> try_pop() {
std::lock_guard<std::mutex> lock(mtx_);
if (data_.empty()) return std::nullopt;
std::optional<T> val = std::move(data_.front());
data_.pop();
return val;
}
// Race-free blocking wait_and_pop:
std::optional<T> wait_and_pop() {
std::unique_lock<std::mutex> lock(mtx_);
cv_.wait(lock, [this]() { return !data_.empty(); });
std::optional<T> val = std::move(data_.front());
data_.pop();
return val;
}
};Always design concurrent queue pop methods to return by value or optional from inside a single locked critical section.
Coarse-Grained vs Fine-Grained (Hand-Over-Hand) Locking in Linked Structures
Coarse-Grained vs Fine-Grained (Hand-Over-Hand) Locking in Linked Structures
When synchronizing linked data structures (linked lists, skip lists, search trees), developers have two fundamental locking choices:
Coarse-Grained Locking
A single std::mutex guards the entire data structure. Every traversal, insertion, and deletion acquires the master lock.
- Pros: Simple, zero deadlock risk, easy exception safety, low memory overhead (1 mutex total).
- Cons: High contention under heavy multithreaded loads; all threads serialize regardless of which nodes they touch.
Fine-Grained Locking (Hand-Over-Hand / Lock Coupling)
Each individual node in the linked structure contains its own std::mutex. When traversing the list, a thread locks Node i+1 before releasing the lock on Node i (like swinging hand-over-hand across monkey bars).
- Pros: Multiple threads can traverse different sections of the list concurrently.
- Cons: Severe performance overhead (locking/unlocking dozens of mutexes per traversal incurs high bus-lock costs), complex deadlock avoidance, and elevated memory footprint (40 bytes per node for
std::mutex). In production C++, sharded locking or lock-free designs are overwhelmingly preferred over hand-over-hand locking.
// Hand-Over-Hand (Lock Coupling) Traversal Sketch:
Node* curr = head;
curr->mtx.lock();
Node* next = curr->next;
while (next != nullptr) {
next->mtx.lock(); // Lock child before releasing parent!
curr->mtx.unlock(); // Release parent
curr = next;
next = curr->next;
}
curr->mtx.unlock();Hand-over-hand locking sounds elegant in theory but introduces massive locking overhead; prefer coarse or sharded locks in practice.
Why Reader-Writer (std::shared_mutex) Fails for LRU Cache get()
Why Reader-Writer (std::shared_mutex) Fails for LRU Cache get()
When interviewers ask candidates to optimize an in-memory Cache for high-read, low-write workloads, candidates frequently suggest std::shared_mutex: "We will use std::shared_lock (read lock) for get(), and std::unique_lock (write lock) for put()!"
The Fatal Flaw in LRU Caches
In a standard key-value map, get(key) is a read-only operation. However, in an LRU (Least Recently Used) Cache, calling get(key) mutates the underlying Doubly Linked List! Specifically, the accessed node must be moved to the front of the list (Most Recently Used position) by updating four pointers:
node->prev->next = node->nextnode->next->prev = node->prevnode->next = head->nexthead->next = node
If two threads execute cache.get() concurrently under a std::shared_lock, both threads attempt to rewrite these four node pointers at the exact same time! This causes a catastrophic data race, dangling pointers, circular linked lists, and memory corruption. In an LRU Cache, get() is a write operation and requires an exclusive lock (or a decoupled promotion queue).
// MISTAKE: shared_lock on LRU get() causes data race on node pointers!
std::optional<V> brokenGet(const K& key) {
std::shared_lock<std::shared_mutex> lock(mtx); // RACE CONDITION!
auto it = map.find(key);
if (it != map.end()) {
moveToFront(it->second); // WRITES to node->prev and node->next! CRASH!
return it->second->value;
}
return std::nullopt;
}An LRU Cache get() operation mutates node recency; it is fundamentally a write operation requiring exclusive synchronization.
Lock Striping (Sharded Locks) for High-Throughput Concurrent Caching
Lock Striping (Sharded Locks) for High-Throughput Concurrent Caching
Because a single coarse mutex serializes all accesses to an LRU Cache, high-throughput systems (such as Memcached, Redis clusters, or database buffer pools) use Lock Striping (Sharding).
Instead of protecting the entire cache with one lock, the cache is partitioned into S independent shards (typically a power of 2, e.g., 16, 32, or 64). Each shard contains its own independent mutex, hash map, and doubly linked list. When a thread accesses key K, it hashes the key to select a shard:
$$shardIndex = hash(K) \pmod S$$
Because different keys map to different shards, operations on unrelated keys proceed in 100% parallel execution without lock contention! Cross-shard operations are rare, and cache eviction is localized to the individual shard with zero inter-thread interference.
template <typename K, typename V, size_t NumShards = 16>
class ShardedLRUCache {
std::array<ThreadSafeLRUCache<K, V>, NumShards> shards_;
size_t getShard(const K& key) const {
return std::hash<K>{}(key) % NumShards;
}
public:
void put(K key, V val) {
shards_[getShard(key)].put(std::move(key), std::move(val));
}
std::optional<V> get(const K& key) {
return shards_[getShard(key)].get(key);
}
};Lock striping scales concurrent cache throughput linearly with shard count by isolating lock contention to independent key subsets.
Unlock Live Implementation & Interview Scenarios
The C++23 production code, live coding steps, scenario twist solutions, and common UB traps are part of Brute2Best Pro. Module 1 is completely free.
First anchor question per pattern, Part 1 LLD & Module 1 C++ basics are 100% Free forever.