Skip to content
C++
Idiom
since C++11
Advanced

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?"

cpp
#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):

  1. Mutual exclusion (resources held exclusively)
  2. Hold-and-wait (holding one resource while waiting for another)
  3. No preemption (can't forcibly take a resource)
  4. 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?"

cpp
#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++?"

cpp
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:

cpp
// 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()?"

cpp
#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?"

cpp
// 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:

cpp
// 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?"

cpp
// 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 Impl fields 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?"

cpp
// 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:

  1. Heap allocation for the "Model" (unless small buffer optimization kicks in, typically ≀16 bytes)
  2. Virtual dispatch on every call (can't be inlined)
  3. Type information stored as part of control block

Alternatives when std::function is too slow:

cpp
// 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

TopicKey answer
Deadlock preventionstd::scoped_lock acquires multiple mutexes atomically (no ordering needed)
memory_order_relaxedAtomicity only; use for counters with no inter-thread ordering
Thread-safe singletonMeyers singleton (static local) β€” C++11 guarantees thread-safe init
Spurious wakeupAlways use predicate form: cv.wait(lock, [&]{return condition;})
CRTP vs virtualCRTP: compile-time, zero overhead, inlineable; virtual: runtime, vtable
PIMPL purposeABI stability + hide internals + faster includes
Type erasureStores any type meeting an interface; std::function uses virtual dispatch internally
std::function costHeap 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.

cpp
#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.

cpp
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.

cpp
// 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.

cpp
#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.

cpp
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 0

This 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.

cpp
#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::mutex fairness matters to avoid starvation

ABA problem (must mention in interviews):

cpp
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.

cpp
#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:

  1. 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.
  2. Re-entrant emission: a slot calling emit() again causes infinite recursion or iterator invalidation. The snapshot technique above prevents the latter.
  3. Thread safety: the basic version above is not thread-safe. Protecting emit() and connect() with a mutex introduces lock inversion if a slot acquires another mutex. Consider std::shared_mutex for 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.

cpp
#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

TopicKey answer
memory_order_relaxed safe usesPer-thread counters, statistics β€” no cross-thread ordering needed
acquire/release pairProducer-consumer flag; all writes before release visible after acquire
seq_cst needed whenMultiple atomic variables must be observed in the same total order by all threads
Thundering herd fixnotify_one() instead of notify_all() when only one thread can consume the task
Work stealing benefitPer-worker deques eliminate centralized queue contention; idle workers steal from busy ones
Lock-free vs mutexLock-free wins for SPSC / no-blocking constraints; mutex often wins for MPMC under contention
ABA problemCAS succeeds on same address after value changed; fix with tagged pointers or hazard pointers
Observer pitfallDangling callbacks β€” use RAII disconnect tokens; snapshot before iterating during emit
Factory return typestd::unique_ptr<Base> β€” transfers ownership, prevents leaks, preserves polymorphism