C++ Interview Prep: Concurrency and Design Patterns
C++ multithreading questions: mutex vs atomic, deadlock prevention, lock-free patterns, and the design idioms (CRTP, PIMPL, type erasure) that appear in senior interviews.
The advanced tier: concurrency questions test whether you understand the memory model; design pattern questions test whether you write extensible, zero-overhead code.
Mutex Pitfalls β Deadlock and Priority Inversion
The question: "How do you prevent deadlock when acquiring multiple mutexes?"
#include <mutex>
std::mutex mutex_a, mutex_b;
// WRONG: inconsistent lock order β deadlock
void thread1() {
std::lock_guard la{mutex_a};
std::lock_guard lb{mutex_b}; // T1: holds A, waits for B
}
void thread2() {
std::lock_guard lb{mutex_b}; // T2: holds B, waits for A β deadlock!
std::lock_guard la{mutex_a};
}
// CORRECT: std::lock() acquires multiple mutexes atomically (deadlock-free)
void safe_thread1() {
std::scoped_lock lock{mutex_a, mutex_b}; // C++17: acquires both atomically
}
// Or C++11 style:
void safe_thread1_cpp11() {
std::unique_lock la{mutex_a, std::defer_lock};
std::unique_lock lb{mutex_b, std::defer_lock};
std::lock(la, lb); // acquires both; if one fails, releases and retries
}The four conditions for deadlock (Coffman conditions):
- Mutual exclusion (resources held exclusively)
- Hold-and-wait (holding one resource while waiting for another)
- No preemption (can't forcibly take a resource)
- Circular wait (A waits for B, B waits for A)
Break any one to prevent deadlock. std::scoped_lock breaks circular wait by using a consistent acquisition protocol.
std::atomic Memory Orders
The question: "When would you use memory_order_relaxed? When is seq_cst needed?"
#include <atomic>
// memory_order_relaxed: atomicity only β no ordering guarantees between operations
// Use for: counters where only the final value matters (no happens-before needed)
std::atomic<int> hits{0};
void log_request() {
hits.fetch_add(1, std::memory_order_relaxed); // correct for a simple counter
}
// memory_order_acquire / release: the core of lock-free patterns
std::atomic<bool> data_ready{false};
int shared_data = 0;
void producer() {
shared_data = 42; // must be visible...
data_ready.store(true, std::memory_order_release); // ...before any acquire sees true
}
void consumer() {
while (!data_ready.load(std::memory_order_acquire)) {} // spin
// guaranteed: shared_data == 42 here
assert(shared_data == 42);
}
// memory_order_seq_cst (default): strongest β global total order visible to all threads
// Use when you need: multiple atomics with cross-thread consistency
// Cost: on x86 it's a MFENCE (full memory barrier); on ARM it's more expensive
std::atomic<bool> x{false}, y{false};
std::atomic<int> z{0};
void write_x() { x.store(true, std::memory_order_seq_cst); }
void write_y() { y.store(true, std::memory_order_seq_cst); }
void read_xy() {
if (x.load(std::memory_order_seq_cst))
if (!y.load(std::memory_order_seq_cst))
++z; // with seq_cst: guaranteed at most one thread increments z
}Rule of thumb: Start with seq_cst (default). Only downgrade to acquire/release after measuring that the default is a bottleneck. Never use relaxed unless you've proven no ordering is needed.
Thread-Safe Singleton (Meyers Singleton)
The question: "How do you implement a thread-safe singleton in C++?"
class Config {
public:
static Config& instance() {
static Config cfg; // guaranteed thread-safe initialization since C++11
return cfg; // compiler inserts a once-flag internally
}
std::string get(std::string_view key) const;
private:
Config() { /* load from file */ }
Config(const Config&) = delete;
Config& operator=(const Config&) = delete;
};
// Usage:
Config::instance().get("db_url");Why this is correct: C++11 Β§6.7 guarantees that static local variable initialization is performed exactly once, even with concurrent calls. The implementation uses a hidden std::once_flag. This is the Meyers Singleton β no std::mutex or std::call_once needed in application code.
What NOT to do:
// Double-checked locking β broken without proper memory barriers (pre-C++11):
Config* Config::instance() {
if (!inst_) { // (1)
std::lock_guard lk{m_};
if (!inst_) // (2)
inst_ = new Config; // (3) β without atomic store, (1) can see partially-constructed Config
}
return inst_;
}
// The C++11 memory model makes this fixable with std::atomic<Config*>,
// but the Meyers singleton is simpler and equally correct.std::condition_variable β Spurious Wakeups
The question: "Why must you always use a predicate with condition_variable::wait()?"
#include <condition_variable>
#include <queue>
#include <mutex>
// Thread-safe queue using condition_variable
template<typename T>
class BlockingQueue {
std::queue<T> q_;
std::mutex mtx_;
std::condition_variable cv_;
public:
void push(T item) {
{
std::lock_guard lock{mtx_};
q_.push(std::move(item));
}
cv_.notify_one();
}
T pop() {
std::unique_lock lock{mtx_};
// WRONG: cv_.wait(lock) β may return spuriously even when queue is empty
// cv_.wait(lock);
// CORRECT: always predicate-check in a loop
cv_.wait(lock, [this] { return !q_.empty(); });
// Equivalent to: while (q_.empty()) cv_.wait(lock);
T item = std::move(q_.front());
q_.pop();
return item;
}
T pop_timeout(std::chrono::milliseconds ms) {
std::unique_lock lock{mtx_};
if (!cv_.wait_for(lock, ms, [this] { return !q_.empty(); }))
throw std::runtime_error("timeout");
T item = std::move(q_.front());
q_.pop();
return item;
}
};Why spurious wakeups exist: On some OS implementations, wait() can return even without notify_*. The standard explicitly permits this. The predicate form automatically re-checks and re-waits.
CRTP β Compile-Time Polymorphism
The question: "What is CRTP and when is it better than virtual functions?"
// Virtual function approach β runtime polymorphism
struct Animal {
virtual void speak() const = 0;
virtual ~Animal() = default;
};
struct Dog : Animal {
void speak() const override { std::println("Woof"); }
};
// CRTP β compile-time polymorphism, no vtable
template<typename Derived>
struct AnimalBase {
void speak() const {
static_cast<const Derived*>(this)->speak_impl(); // resolved at compile time
}
};
struct Cat : AnimalBase<Cat> {
void speak_impl() const { std::println("Meow"); }
};
// CRTP avoids vtable overhead β useful in hot loops (game components, numerical code)
template<typename D>
void make_noise(const AnimalBase<D>& a) {
a.speak(); // inlined, no indirect call
}Adding mixin functionality via CRTP:
// CRTP mixin: add serialization to any class without virtual functions
template<typename Derived>
struct Serializable {
std::string to_json() const {
const auto& d = static_cast<const Derived&>(*this);
return d.serialize_fields(); // derived must implement serialize_fields()
}
};
struct Point : Serializable<Point> {
int x, y;
std::string serialize_fields() const {
return std::format(R"({{"x":{},"y":{}}})", x, y);
}
};
Point p{1, 2};
std::println("{}", p.to_json()); // {"x":1,"y":2}CRTP when it's wrong: If you need runtime polymorphism (store different types in the same container, select behavior at runtime), use virtual functions. CRTP only helps when the type is known at compile time.
PIMPL Idiom β ABI Stability
The question: "What is the PIMPL idiom and why is it used?"
// widget.hpp β public API; never exposes internal headers
class Widget {
public:
explicit Widget(int id);
~Widget(); // must be in .cpp (Impl is incomplete in header)
Widget(Widget&&) noexcept;
Widget& operator=(Widget&&) noexcept;
void draw();
int id() const;
private:
struct Impl; // forward declaration β no internal details leaked
std::unique_ptr<Impl> pImpl_; // pointer to implementation
};
// widget.cpp β only TU that sees internal headers
#include "widget.hpp"
#include <opengl/opengl.hpp> // heavy, platform-specific header β NOT in widget.hpp!
#include <internal_db.hpp>
struct Widget::Impl {
int id;
OpenGLContext ctx;
DatabaseHandle db;
// ...heavy implementation details...
};
Widget::Widget(int id) : pImpl_(std::make_unique<Impl>(id)) {}
Widget::~Widget() = default; // unique_ptr destructor needs Impl's definition
Widget::Widget(Widget&&) noexcept = default;
Widget& Widget::operator=(Widget&&) noexcept = default;
void Widget::draw() { pImpl_->ctx.render(); }
int Widget::id() const { return pImpl_->id; }What PIMPL buys:
- Changing
Implfields doesn't break binary compatibility βsizeof(Widget)stays fixed - Consumers don't need to include
<opengl/opengl.hpp>β faster compilation - Implementation details are truly hidden (not just
private:)
Cost: One extra heap allocation per Widget; one extra pointer dereference per method call.
Type Erasure
The question: "What is type erasure in C++? How does std::function implement it?"
// Type erasure: store any type that satisfies an interface, without inheritance
// std::function<R(Args...)> is the canonical example
// How it works internally:
class AnyCallable {
struct Concept {
virtual int call(int) const = 0;
virtual ~Concept() = default;
};
template<typename F>
struct Model : Concept {
F func;
explicit Model(F f) : func(std::move(f)) {}
int call(int x) const override { return func(x); }
};
std::unique_ptr<Concept> impl_;
public:
template<typename F>
AnyCallable(F f) : impl_(std::make_unique<Model<F>>(std::move(f))) {}
int operator()(int x) const { return impl_->call(x); }
};
// Usage:
AnyCallable f = [](int x) { return x * 2; };
AnyCallable g = [](int x) { return x + 10; };
std::vector<AnyCallable> transforms{std::move(f), std::move(g)};std::function costs:
- Heap allocation for the "Model" (unless small buffer optimization kicks in, typically β€16 bytes)
- Virtual dispatch on every call (can't be inlined)
- Type information stored as part of control block
Alternatives when std::function is too slow:
// Templates: zero overhead, but caller must know type at compile time
template<typename Callable>
void apply(Callable&& fn, int x) { fn(x); }
// std::move_only_function (C++23): no copy required, slightly less overhead
std::move_only_function<int(int)> f = [capture = std::string{"hello"}](int x) {
return x + (int)capture.size();
};Quick Reference
| Topic | Key answer |
|---|---|
| Deadlock prevention | std::scoped_lock acquires multiple mutexes atomically (no ordering needed) |
memory_order_relaxed | Atomicity only; use for counters with no inter-thread ordering |
| Thread-safe singleton | Meyers singleton (static local) β C++11 guarantees thread-safe init |
| Spurious wakeup | Always use predicate form: cv.wait(lock, [&]{return condition;}) |
| CRTP vs virtual | CRTP: compile-time, zero overhead, inlineable; virtual: runtime, vtable |
| PIMPL purpose | ABI stability + hide internals + faster includes |
| Type erasure | Stores any type meeting an interface; std::function uses virtual dispatch internally |
std::function cost | Heap alloc (unless SBO fits) + vtable call per invocation |
std::atomic Memory Ordering β seq_cst vs acquire/release vs relaxed
The question: "Explain the three most common memory orders. When is relaxed safe and when must you use seq_cst?"
memory_order_relaxed provides atomicity but no synchronization between threads β the compiler and CPU are free to reorder other loads and stores around it.
#include <atomic>
#include <thread>
#include <cassert>
// SAFE with relaxed: a per-thread counter that only needs to be globally
// consistent once all threads join (no "happens-before" required mid-flight).
std::atomic<long> event_count{0};
void worker() {
for (int i = 0; i < 100'000; ++i)
event_count.fetch_add(1, std::memory_order_relaxed);
}
// After join(), the total is correct even with relaxed β because the
// thread join itself establishes the necessary happens-before edge.memory_order_acquire / memory_order_release form a synchronization pair: a release store on thread A is visible to any acquire load on thread B that reads the stored value, and all writes performed before the release are also visible after the acquire.
std::atomic<bool> ready{false};
int payload = 0;
void producer() {
payload = 42; // (1) write payload
ready.store(true, std::memory_order_release); // (2) publish
}
void consumer() {
while (!ready.load(std::memory_order_acquire)) {} // spin until (2) visible
assert(payload == 42); // (1) is guaranteed visible here
}memory_order_seq_cst (the default) imposes a single total modification order observable by all threads across all atomic variables. It is the only order that prevents the "IRIW" (independent reads of independent writes) reordering that acquire/release cannot forbid.
// Dekker-style mutual exclusion demo β only correct with seq_cst
std::atomic<bool> flag_a{false}, flag_b{false};
void thread_a() {
flag_a.store(true, std::memory_order_seq_cst);
if (!flag_b.load(std::memory_order_seq_cst)) {
// critical section β guaranteed mutually exclusive with thread_b
}
}
void thread_b() {
flag_b.store(true, std::memory_order_seq_cst);
if (!flag_a.load(std::memory_order_seq_cst)) {
// critical section
}
}
// With acquire/release, both threads could observe the other's flag as false
// simultaneously; seq_cst prevents that.Interview rule: use relaxed for counters / statistics with no ordering requirement; use acquire/release for producer-consumer publication patterns; use seq_cst when multiple atomic variables must be observed in a consistent global order. Downgrade only after benchmarking proves seq_cst is a bottleneck.
Thread Pool Design β Task Queues and Work Stealing
The question: "How would you design a thread pool? What is work stealing and why does it help?"
A basic fixed-size thread pool uses one shared lock-free or mutex-protected queue and N worker threads that pull tasks from it.
#include <functional>
#include <thread>
#include <vector>
#include <queue>
#include <mutex>
#include <condition_variable>
#include <atomic>
class ThreadPool {
public:
explicit ThreadPool(std::size_t n_threads) {
workers_.reserve(n_threads);
for (std::size_t i = 0; i < n_threads; ++i) {
workers_.emplace_back([this] { run(); });
}
}
~ThreadPool() {
{
std::lock_guard lk{mtx_};
stop_ = true;
}
cv_.notify_all();
for (auto& t : workers_) t.join();
}
template<typename F>
void submit(F&& task) {
{
std::lock_guard lk{mtx_};
tasks_.emplace(std::forward<F>(task));
}
cv_.notify_one(); // wake exactly one idle worker
}
private:
void run() {
while (true) {
std::function<void()> task;
{
std::unique_lock lk{mtx_};
cv_.wait(lk, [this] { return stop_ || !tasks_.empty(); });
if (stop_ && tasks_.empty()) return;
task = std::move(tasks_.front());
tasks_.pop();
}
task();
}
}
std::vector<std::thread> workers_;
std::queue<std::function<void()>> tasks_;
std::mutex mtx_;
std::condition_variable cv_;
bool stop_{false};
};Thundering herd: notify_all() on task submission wakes every sleeping thread simultaneously, causing them all to race for the mutex. Only one wins; the rest go back to sleep β wasted context switches. The fix is notify_one() (as above) for tasks that can only be consumed by one worker.
Work stealing addresses the opposite problem: uneven load distribution. Each worker owns a private double-ended queue (deque). Workers push/pop from their own deque's back (fast, no contention). When a worker runs out of tasks, it "steals" from the front of another worker's deque.
Worker 0 deque: [T1 T2 T3] β push/pop from back (owner)
β steal from front (thieves)
Worker 1 deque: [] β idle, tries to steal T1 from Worker 0This is the design used by Intel TBB and the C++ executor proposals. It nearly eliminates the thundering herd problem and balances load automatically without a centralized queue becoming a bottleneck.
Key design decisions to mention in an interview:
- Task granularity: too fine β overhead dominates; too coarse β underutilization
- Queue type:
std::queue+ mutex is simple; Chase-Lev lock-free deque for work stealing - Thread count: typically
std::thread::hardware_concurrency()for CPU-bound; more for I/O-bound - Backpressure: bound the queue size to avoid unbounded memory growth under load
Lock-Free Data Structures β When They're Actually Faster
The question: "Are lock-free data structures always faster than mutex-based ones? When should you use each?"
Lock-free does not mean fast by default. A mutex-protected structure can be faster under low contention because a std::mutex is essentially a single CAS (compare-and-swap) instruction on x86 when uncontended. Lock-free structures use CAS loops that spin on contention, and the cache-coherency traffic from shared atomics can be expensive.
#include <atomic>
// Lock-free SPSC (single-producer, single-consumer) ring buffer
// β genuinely faster than mutex in this specific topology
template<typename T, std::size_t N>
class SPSCQueue {
static_assert((N & (N - 1)) == 0, "N must be power of two");
T buf_[N];
std::atomic<std::size_t> head_{0}, tail_{0};
public:
bool push(T val) {
std::size_t t = tail_.load(std::memory_order_relaxed);
std::size_t next = (t + 1) & (N - 1);
if (next == head_.load(std::memory_order_acquire)) return false; // full
buf_[t] = std::move(val);
tail_.store(next, std::memory_order_release);
return true;
}
bool pop(T& out) {
std::size_t h = head_.load(std::memory_order_relaxed);
if (h == tail_.load(std::memory_order_acquire)) return false; // empty
out = std::move(buf_[h]);
head_.store((h + 1) & (N - 1), std::memory_order_release);
return true;
}
};
// No mutex, no CAS loop β works because only one thread reads head_ and
// only one thread writes tail_.When lock-free wins:
- SPSC queues in audio/networking pipelines (hard real-time, no lock latency spikes)
- High-frequency counters where the alternative is a mutex held for nanoseconds
- When threads cannot block (interrupt handlers, signal handlers, lock-free logging)
When mutex wins:
- MPMC (multi-producer, multi-consumer) scenarios: correct lock-free MPMC is very hard to implement and the ABA problem requires hazard pointers or epoch-based reclamation
- When critical sections are short: an uncontended mutex acquire on x86 costs ~20β40 cycles; a failed CAS and spin can cost far more under contention
- When
std::mutexfairness matters to avoid starvation
ABA problem (must mention in interviews):
Thread A reads value = ptr_X (address 0x1000)
Thread B frees ptr_X, allocates new node at 0x1000 (same address), inserts it
Thread A's CAS(expected=0x1000, desired=next) succeeds β but the node changed!Solutions: tagged pointers (pack a version counter into unused pointer bits), std::atomic with 128-bit CAS on platforms that support it, or hazard pointers (std::hazard_pointer in C++26).
Observer Pattern β Signal/Slot and Event Systems
The question: "How do you implement the Observer pattern in modern C++? What are the pitfalls?"
The Observer pattern decouples an event source (Subject) from its listeners (Observers) so that adding new listeners requires no changes to the subject. It appears in GUI frameworks (Qt's signals/slots), pub-sub systems, and reactive pipelines.
#include <functional>
#include <vector>
#include <algorithm>
// Lightweight signal β holds a list of callbacks, notifies all on emit()
template<typename... Args>
class Signal {
public:
using Slot = std::function<void(Args...)>;
// Returns a token that can be used to disconnect
std::size_t connect(Slot slot) {
slots_.push_back({next_id_++, std::move(slot)});
return slots_.back().id;
}
void disconnect(std::size_t id) {
std::erase_if(slots_, [id](const Entry& e) { return e.id == id; });
}
void emit(Args... args) const {
// Snapshot in case a slot disconnects itself during notification
auto snapshot = slots_;
for (const auto& e : snapshot)
e.slot(args...);
}
private:
struct Entry { std::size_t id; Slot slot; };
std::vector<Entry> slots_;
std::size_t next_id_{0};
};
// Usage:
Signal<int, std::string> on_event;
auto id = on_event.connect([](int code, std::string msg) {
std::println("Event {}: {}", code, msg);
});
on_event.emit(200, "OK");
on_event.disconnect(id);Common pitfalls:
- Dangling observers: if an observer is destroyed but not disconnected, the subject holds a dangling
std::function. Fix: use RAII connection tokens that auto-disconnect in their destructor. - Re-entrant emission: a slot calling
emit()again causes infinite recursion or iterator invalidation. The snapshot technique above prevents the latter. - Thread safety: the basic version above is not thread-safe. Protecting
emit()andconnect()with a mutex introduces lock inversion if a slot acquires another mutex. Considerstd::shared_mutexfor read-heavy signal hierarchies.
Factory Pattern β virtual Constructor and std::unique_ptr
The question: "How do you implement a Factory in C++? How does it differ from Java's approach?"
A Factory creates objects of derived types without the caller knowing the concrete class. In Java, all objects are heap-allocated references so this is trivial. In C++, you must return by pointer (preferably std::unique_ptr) to preserve polymorphism.
#include <memory>
#include <string>
#include <unordered_map>
#include <functional>
#include <stdexcept>
// Abstract product
struct Compressor {
virtual std::vector<std::byte> compress(std::span<const std::byte>) = 0;
virtual ~Compressor() = default;
};
struct ZstdCompressor : Compressor { /* ... */ std::vector<std::byte> compress(std::span<const std::byte>) override { return {}; } };
struct LZ4Compressor : Compressor { /* ... */ std::vector<std::byte> compress(std::span<const std::byte>) override { return {}; } };
struct SnappyCompressor: Compressor { /* ... */ std::vector<std::byte> compress(std::span<const std::byte>) override { return {}; } };
// Registry-based factory β new compressor types self-register without modifying factory code
class CompressorFactory {
using Creator = std::function<std::unique_ptr<Compressor>()>;
inline static std::unordered_map<std::string, Creator> registry_;
public:
static void register_type(std::string name, Creator creator) {
registry_.emplace(std::move(name), std::move(creator));
}
static std::unique_ptr<Compressor> create(const std::string& name) {
auto it = registry_.find(name);
if (it == registry_.end())
throw std::invalid_argument("Unknown compressor: " + name);
return it->second();
}
};
// Self-registration at program startup via static initializer
struct ZstdRegistrar {
ZstdRegistrar() {
CompressorFactory::register_type("zstd",
[] { return std::make_unique<ZstdCompressor>(); });
}
} zstd_reg;
// Caller:
auto c = CompressorFactory::create("zstd");
auto data = c->compress(input_bytes);Why unique_ptr not raw pointer: the caller takes ownership; unique_ptr makes that explicit and prevents leaks when the caller forgets to delete.
Interview follow-up β what pattern does this extend to? The registry pattern above is essentially the Abstract Factory when create() is parameterized by multiple dimensions (e.g., create(format, compression_level)).
Updated Quick Reference
| Topic | Key answer |
|---|---|
memory_order_relaxed safe uses | Per-thread counters, statistics β no cross-thread ordering needed |
acquire/release pair | Producer-consumer flag; all writes before release visible after acquire |
seq_cst needed when | Multiple atomic variables must be observed in the same total order by all threads |
| Thundering herd fix | notify_one() instead of notify_all() when only one thread can consume the task |
| Work stealing benefit | Per-worker deques eliminate centralized queue contention; idle workers steal from busy ones |
| Lock-free vs mutex | Lock-free wins for SPSC / no-blocking constraints; mutex often wins for MPMC under contention |
| ABA problem | CAS succeeds on same address after value changed; fix with tagged pointers or hazard pointers |
| Observer pitfall | Dangling callbacks β use RAII disconnect tokens; snapshot before iterating during emit |
| Factory return type | std::unique_ptr<Base> β transfers ownership, prevents leaks, preserves polymorphism |