Menu

Earn Premium with Referrals

Invite your friends and earn Premium rewards through our referral program.

See how it works and start inviting friends.

Concurrency & Atomics
C++

Concurrency & Atomics

Practice 13 questions covering threads, mutexes, synchronization, atomic operations, race conditions, and concurrency.

1. Which std::memory_order provides the strongest synchronization guarantee in atomic operations?

Answer: std::memory_order_seq_cst (sequentially consistent).

The memory order argument to atomic operations controls how much reordering the compiler and CPU may perform around them. From weakest to strongest:

  • memory_order_relaxed — no ordering guarantees beyond the atomicity of the operation itself.
  • memory_order_acquire / release — one-way ordering for producer-consumer patterns.
  • memory_order_seq_cst — the default and the strongest: all operations tagged sequentially consistent appear in a single globally agreed-upon total order across every thread.

With seq_cst, if thread A observes a value stored before some operation by thread B, then every thread agrees on the ordering — there’s one consistent view of all the seq_cst operations. This is the easiest to reason about (matches intuitive “happens-before” thinking) at the cost of more synchronization on the hardware.

It’s also the default: std::atomic<T>::load()/store() use seq_cst unless you specify otherwise. The interview answer: std::memory_order_seq_cst — one total order of all atomic operations, preventing reordering across the fence.

Answer:

std::memory_order_seq_cst (sequentially consistent).

The memory order argument to atomic operations controls how much reordering the compiler and CPU may perform around them. From weakest to strongest:

  • memory_order_relaxed — no ordering guarantees beyond the atomicity of the operation itself.
  • memory_order_acquire / release — one-way ordering for producer-consumer patterns.
  • memory_order_seq_cst — the default and the strongest: all operations tagged sequentially consistent appear in a single globally agreed-upon total order across every thread.

With seq_cst, if thread A observes a value stored before some operation by thread B, then every thread agrees on the ordering — there’s one consistent view of all the seq_cst operations. This is the easiest to reason about (matches intuitive “happens-before” thinking) at the cost of more synchronization on the hardware.

It’s also the default: std::atomic<T>::load()/store() use seq_cst unless you specify otherwise. The interview answer: std::memory_order_seq_cst — one total order of all atomic operations, preventing reordering across the fence.

2. What is the difference between std::thread and C++20 std::jthread?

Answer: std::jthread automatically joins on destruction and supports cooperative cancellation via std::stop_token.

The pain point with std::thread is RAII-discipline: a std::thread that goes out of scope while still joinable (not joined, not detached) calls std::terminate(). You must remember to join() or detach() manually — easy to get wrong in the presence of exceptions or early returns.

std::jthread fixes the lifetime problem: its destructor automatically requests cancellation and joins the thread, so you can’t forget. It’s safe under exceptions because destruction happens during unwinding.

It also adds cooperative cancellation: each jthread has an associated std::stop_token. Long-running work can check token.stop_requested() and exit early; the thread’s destructor can request a stop, giving a clean shutdown path instead of just killing the thread.

The interview answer: jthread auto-joins on destruction (RAII) and supports std::stop_token-based cancellation, unlike std::thread.

Answer:

std::jthread automatically joins on destruction and supports cooperative cancellation via std::stop_token.

The pain point with std::thread is RAII-discipline: a std::thread that goes out of scope while still joinable (not joined, not detached) calls std::terminate(). You must remember to join() or detach() manually — easy to get wrong in the presence of exceptions or early returns.

std::jthread fixes the lifetime problem: its destructor automatically requests cancellation and joins the thread, so you can’t forget. It’s safe under exceptions because destruction happens during unwinding.

It also adds cooperative cancellation: each jthread has an associated std::stop_token. Long-running work can check token.stop_requested() and exit early; the thread’s destructor can request a stop, giving a clean shutdown path instead of just killing the thread.

The interview answer: jthread auto-joins on destruction (RAII) and supports std::stop_token-based cancellation, unlike std::thread.

3. What is the underlying cause of “False Sharing” in concurrent C++ programs?

Answer: Independent variables used by different threads happen to sit on the same CPU cache line, causing cache-line invalidation thrashing.

CPU caches are organized in lines (typically 64 bytes). When a core writes to memory, it must own the cache line — which means invalidating that line on all other cores. Now suppose two independent variables a and b (accessed only by thread 1 and thread 2 respectively) land on the same cache line. There’s no data race — the threads touch different variables. But:

  • Thread 1 writes a → invalidates the line on thread 2’s core.
  • Thread 2 writes b → invalidates the line on thread 1’s core.

Every write forces a cache miss on the other core, even though neither thread touched the other’s data. Performance collapses, despite correctness being fine. That’s “false” sharing — the sharing is an artifact of cache-line granularity, not of actual data sharing.

The fix: padding — align or space the variables so they occupy separate cache lines (alignas(64), or pad the struct to a multiple of 64 bytes). The interview answer: distinct variables sharing a cache line cause invalidation thrashing between cores — no logical race, but a severe performance problem.

Answer:

Independent variables used by different threads happen to sit on the same CPU cache line, causing cache-line invalidation thrashing.

CPU caches are organized in lines (typically 64 bytes). When a core writes to memory, it must own the cache line — which means invalidating that line on all other cores. Now suppose two independent variables a and b (accessed only by thread 1 and thread 2 respectively) land on the same cache line. There’s no data race — the threads touch different variables. But:

  • Thread 1 writes a → invalidates the line on thread 2’s core.
  • Thread 2 writes b → invalidates the line on thread 1’s core.

Every write forces a cache miss on the other core, even though neither thread touched the other’s data. Performance collapses, despite correctness being fine. That’s “false” sharing — the sharing is an artifact of cache-line granularity, not of actual data sharing.

The fix: padding — align or space the variables so they occupy separate cache lines (alignas(64), or pad the struct to a multiple of 64 bytes). The interview answer: distinct variables sharing a cache line cause invalidation thrashing between cores — no logical race, but a severe performance problem.

4. What is the purpose of std::scoped_lock introduced in C++17?

Answer: To lock multiple mutexes at once, deadlock-free, with RAII cleanup — one object, unlock on destruction.

Before C++17, locking several mutexes safely required std::lock(m1, m2) (which uses a deadlock-avoidance algorithm to acquire both without a deadlock race) plus a separate guard to manage unlocking. That split is easy to get wrong.

std::scoped_lock combines both: you hand it multiple mutexes and it locks them with the same deadlock-avoidance algorithm as std::lock, then unlocks all of them automatically when the lock object goes out of scope (RAII — safe under exceptions).

std::scoped_lock lock(m1, m2);   // both locked, deadlock-free
// ... critical section ...
// destruction unlocks both

It also works with a single mutex, making it the drop-in successor to std::lock_guard. The interview answer: deadlock-free simultaneous locking of multiple mutexes via an RAII wrapper, with automatic unlock on scope exit.

Answer:

To lock multiple mutexes at once, deadlock-free, with RAII cleanup — one object, unlock on destruction.

Before C++17, locking several mutexes safely required std::lock(m1, m2) (which uses a deadlock-avoidance algorithm to acquire both without a deadlock race) plus a separate guard to manage unlocking. That split is easy to get wrong.

std::scoped_lock combines both: you hand it multiple mutexes and it locks them with the same deadlock-avoidance algorithm as std::lock, then unlocks all of them automatically when the lock object goes out of scope (RAII — safe under exceptions).

std::scoped_lock lock(m1, m2);   // both locked, deadlock-free
// ... critical section ...
// destruction unlocks both

It also works with a single mutex, making it the drop-in successor to std::lock_guard. The interview answer: deadlock-free simultaneous locking of multiple mutexes via an RAII wrapper, with automatic unlock on scope exit.

5. What makes std::atomic_ref (C++20) unique compared to standard std::atomic<T>?

Answer: It provides atomic operations on a non-atomic variable, without taking ownership of its storage.

std::atomic<T> owns the storage it operates on — you declare an atomic<int> and all access to that int goes through it. std::atomic_ref<T> is the opposite: you create a reference-like view over an existing plain variable, and use that view for atomic operations.

int x = 0;                          // ordinary, non-atomic variable
std::atomic_ref<int> ax(x);         // atomic view over x
ax.fetch_add(1);                    // atomic read-modify-write on x

Why this matters: you can do plain, fast (non-atomic) operations on x during single-threaded phases of a program, then switch to atomic operations on the same storage during concurrent phases — with one underlying object. You can even create multiple atomic_refs to different members of a struct. It requires no copying of the storage and doesn’t change the object’s lifetime.

The interview answer: atomic_ref enables atomic operations on non-atomic variables via a temporary view, without owning the storage — plain and atomic access to the same object as needed.

Answer:

It provides atomic operations on a non-atomic variable, without taking ownership of its storage.

std::atomic<T> owns the storage it operates on — you declare an atomic<int> and all access to that int goes through it. std::atomic_ref<T> is the opposite: you create a reference-like view over an existing plain variable, and use that view for atomic operations.

int x = 0;                          // ordinary, non-atomic variable
std::atomic_ref<int> ax(x);         // atomic view over x
ax.fetch_add(1);                    // atomic read-modify-write on x

Why this matters: you can do plain, fast (non-atomic) operations on x during single-threaded phases of a program, then switch to atomic operations on the same storage during concurrent phases — with one underlying object. You can even create multiple atomic_refs to different members of a struct. It requires no copying of the storage and doesn’t change the object’s lifetime.

The interview answer: atomic_ref enables atomic operations on non-atomic variables via a temporary view, without owning the storage — plain and atomic access to the same object as needed.

6. What causes a std::system_error exception during std::thread construction?

Answer: The operating system fails to create the underlying thread — resource exhaustion or hitting OS thread limits.

std::thread isn’t a user-space abstraction; constructing one asks the OS to spawn a native thread. When that OS-level creation fails — out of memory, the process has hit its thread limit, or the OS is otherwise out of resources — the constructor throws std::system_error (which wraps the OS error code).

The constructor does throw on failure (it doesn’t silently leave you with an invalid thread). Other causes of thread-related system errors include permission issues or passing a std::thread in a move-invalid state — but the canonical case is OS thread creation failing due to exhaustion.

The interview answer: OS-level thread creation fails (resource exhaustion / thread limits), and the constructor throws std::system_error.

Answer:

The operating system fails to create the underlying thread — resource exhaustion or hitting OS thread limits.

std::thread isn’t a user-space abstraction; constructing one asks the OS to spawn a native thread. When that OS-level creation fails — out of memory, the process has hit its thread limit, or the OS is otherwise out of resources — the constructor throws std::system_error (which wraps the OS error code).

The constructor does throw on failure (it doesn’t silently leave you with an invalid thread). Other causes of thread-related system errors include permission issues or passing a std::thread in a move-invalid state — but the canonical case is OS thread creation failing due to exhaustion.

The interview answer: OS-level thread creation fails (resource exhaustion / thread limits), and the constructor throws std::system_error.

7. What is the functionality of std::call_once combined with std::once_flag?

Answer: It guarantees a callable executes exactly once, even when multiple threads invoke std::call_once concurrently with the same flag.

std::call_once(flag, func) is the thread-safe “run this initialization exactly once” primitive:

  • Whichever thread reaches std::call_once first runs func.
  • All other threads block until that run completes.
  • If func throws, the flag resets and another thread will retry it; if it completes normally, the flag is set and later calls do nothing.
std::once_flag initFlag;
void ensureInit() {
    std::call_once(initFlag, [] { /* expensive, once-only init */ });
}

This is the classic pattern for lazy thread-safe initialization without mutexes — the standard library’s answer to the double-checked locking problem. The interview answer: run a callable exactly once across all threads, blocking contenders until it finishes.

Answer:

It guarantees a callable executes exactly once, even when multiple threads invoke std::call_once concurrently with the same flag.

std::call_once(flag, func) is the thread-safe “run this initialization exactly once” primitive:

  • Whichever thread reaches std::call_once first runs func.
  • All other threads block until that run completes.
  • If func throws, the flag resets and another thread will retry it; if it completes normally, the flag is set and later calls do nothing.
std::once_flag initFlag;
void ensureInit() {
    std::call_once(initFlag, [] { /* expensive, once-only init */ });
}

This is the classic pattern for lazy thread-safe initialization without mutexes — the standard library’s answer to the double-checked locking problem. The interview answer: run a callable exactly once across all threads, blocking contenders until it finishes.

8. What happens when calling std::future::get() a second time on the same std::future instance?

Answer: It throws std::future_error with std::future_errc::no_state — the shared state was invalidated by the first get().

A std::future is single-use: it owns a handle to shared asynchronous state. The first call to .get() retrieves the result (or rethrows the exception) and invalidates the future — the shared state is moved out. Any subsequent call to .get() finds no state and throws std::future_error.

The same applies to calling .get() on a default-constructed (never-created) future. If you need to fetch the result more than once, use std::shared_future, which supports repeated .get() calls. The interview answer: a second .get() throws std::future_error (no_state) because the first call invalidated the future.

Answer:

It throws std::future_error with std::future_errc::no_state — the shared state was invalidated by the first get().

A std::future is single-use: it owns a handle to shared asynchronous state. The first call to .get() retrieves the result (or rethrows the exception) and invalidates the future — the shared state is moved out. Any subsequent call to .get() finds no state and throws std::future_error.

The same applies to calling .get() on a default-constructed (never-created) future. If you need to fetch the result more than once, use std::shared_future, which supports repeated .get() calls. The interview answer: a second .get() throws std::future_error (no_state) because the first call invalidated the future.

9. What is the fundamental difference between std::atomic::compare_exchange_strong and std::atomic::compare_exchange_weak?

Answer: compare_exchange_weak may fail spuriously (return false even when the values match), but is faster in retry loops; compare_exchange_strong never fails spuriously.

Both compare the current value against expected and, if equal, store desired; if not, load the actual value into expected. The difference is on success detection:

  • On Load-Linked/Store-Conditional (LL/SC) architectures like ARM, the store can be aborted spuriously — interrupted by a cache contention — even though the comparison matched. compare_exchange_weak exposes this: it can return false for no logical reason.
  • compare_exchange_strong retries internally so it never reports a spurious failure — at a small performance cost.

Since spurious failures are always retried in a loop anyway, weak is preferred in CAS loops for performance. The interview answer: weak can fail spuriously but is faster in loops (especially ARM LL/SC); strong never fails spuriously.

Answer:

compare_exchange_weak may fail spuriously (return false even when the values match), but is faster in retry loops; compare_exchange_strong never fails spuriously.

Both compare the current value against expected and, if equal, store desired; if not, load the actual value into expected. The difference is on success detection:

  • On Load-Linked/Store-Conditional (LL/SC) architectures like ARM, the store can be aborted spuriously — interrupted by a cache contention — even though the comparison matched. compare_exchange_weak exposes this: it can return false for no logical reason.
  • compare_exchange_strong retries internally so it never reports a spurious failure — at a small performance cost.

Since spurious failures are always retried in a loop anyway, weak is preferred in CAS loops for performance. The interview answer: weak can fail spuriously but is faster in loops (especially ARM LL/SC); strong never fails spuriously.

10. What is the effect of declaring a variable using thread_local?

Answer: A distinct instance is created per thread, initialized at thread start and destroyed at thread end.

thread_local is a storage-class specifier. Each thread gets its own copy of the variable — no sharing, no synchronization needed for the variable itself. Its lifetime spans the thread: it’s created when the thread accesses it (or at thread start), persists across all function calls on that thread, and is destroyed when the thread terminates.

thread_local int counter = 0;  // every thread has its own counter

This is the standard tool for per-thread state: thread-local caches, thread IDs, per-thread accumulators. The interview answer: one independent instance per thread, initialized at thread start and destroyed at thread end.

Answer:

A distinct instance is created per thread, initialized at thread start and destroyed at thread end.

thread_local is a storage-class specifier. Each thread gets its own copy of the variable — no sharing, no synchronization needed for the variable itself. Its lifetime spans the thread: it’s created when the thread accesses it (or at thread start), persists across all function calls on that thread, and is destroyed when the thread terminates.

thread_local int counter = 0;  // every thread has its own counter

This is the standard tool for per-thread state: thread-local caches, thread IDs, per-thread accumulators. The interview answer: one independent instance per thread, initialized at thread start and destroyed at thread end.

11. What is the primary difference between std::lock_guard and std::unique_lock?

Answer: std::unique_lock provides flexibility (deferred locking, manual unlock/lock, condition-variable support, moveability) at extra cost; std::lock_guard is a minimal RAII scope lock.

std::lock_guard is the simple case: it locks a mutex on construction and unlocks on destruction. Nothing more — lightweight, zero flexibility, perfect for a scoped critical section.

std::unique_lock adds capabilities on top:

  • Manual locking/unlocking — lock and unlock at arbitrary points (lock(), unlock()).
  • Deferred locking — construct without locking, lock later (std::defer_lock).
  • Condition-variable supportstd::condition_variable::wait() requires a unique_lock.
  • Moveable — you can return or store the lock.
  • Timeouts / try-locktry_lock_for, try_lock_until.

Trade-off: unique_lock carries a bit more overhead (it tracks whether it owns the lock). Rule of thumb: use lock_guard unless you need one of unique_lock’s features. The interview answer: lock_guard is a lightweight RAII scope lock; unique_lock adds manual locking, deferred locking, condition-variable support, and moveability.

Answer:

std::unique_lock provides flexibility (deferred locking, manual unlock/lock, condition-variable support, moveability) at extra cost; std::lock_guard is a minimal RAII scope lock.

std::lock_guard is the simple case: it locks a mutex on construction and unlocks on destruction. Nothing more — lightweight, zero flexibility, perfect for a scoped critical section.

std::unique_lock adds capabilities on top:

  • Manual locking/unlocking — lock and unlock at arbitrary points (lock(), unlock()).
  • Deferred locking — construct without locking, lock later (std::defer_lock).
  • Condition-variable supportstd::condition_variable::wait() requires a unique_lock.
  • Moveable — you can return or store the lock.
  • Timeouts / try-locktry_lock_for, try_lock_until.

Trade-off: unique_lock carries a bit more overhead (it tracks whether it owns the lock). Rule of thumb: use lock_guard unless you need one of unique_lock’s features. The interview answer: lock_guard is a lightweight RAII scope lock; unique_lock adds manual locking, deferred locking, condition-variable support, and moveability.

12. What is the function of std::condition_variable::wait() when passed a unique lock reference?

Answer: It atomically releases the mutex and suspends the thread, then re-acquires the mutex before returning after being notified.

wait(lock) does a carefully coordinated dance to avoid missed wakeups:

  1. The calling thread must already hold the mutex (via the unique_lock).
  2. wait() atomically releases the mutex and blocks the thread — “atomic” is critical: it’s a single step, so a notification arriving between release and block can’t be missed.
  3. When notify_one()/notify_all() (or a spurious wakeup) wakes the thread, wait() re-acquires the mutex before returning — because the caller is expected to inspect the shared condition again while holding the lock.

Because spurious wakeups are possible, the standard pattern wraps wait in a predicate loop:

cv.wait(lock, [] { return ready; });   // loops until ready

The interview answer: atomically unlock + sleep, and on notification re-acquire the mutex before returning — always loop on a predicate to handle spurious wakeups.

Answer:

It atomically releases the mutex and suspends the thread, then re-acquires the mutex before returning after being notified.

wait(lock) does a carefully coordinated dance to avoid missed wakeups:

  1. The calling thread must already hold the mutex (via the unique_lock).
  2. wait() atomically releases the mutex and blocks the thread — “atomic” is critical: it’s a single step, so a notification arriving between release and block can’t be missed.
  3. When notify_one()/notify_all() (or a spurious wakeup) wakes the thread, wait() re-acquires the mutex before returning — because the caller is expected to inspect the shared condition again while holding the lock.

Because spurious wakeups are possible, the standard pattern wraps wait in a predicate loop:

cv.wait(lock, [] { return ready; });   // loops until ready

The interview answer: atomically unlock + sleep, and on notification re-acquire the mutex before returning — always loop on a predicate to handle spurious wakeups.

13. What is the outcome of compiling and running a program containing a Data Race in C++?

Answer: Undefined Behavior — the program may corrupt memory, compute wrong results, or crash; there is no defined outcome.

A data race is: two threads access the same memory location concurrently, at least one access is a write, and there’s no synchronization between them (no mutex, atomic, or happens-before edge). In the C++ memory model this is explicitly undefined behavior:

  • The compiler may reorder or eliminate the racing accesses based on the assumption of no race.
  • The observed result can differ run-to-run, including corrupted values and crashes.
  • Nothing “fixes” it automatically — the runtime won’t upgrade conflicting accesses to atomics.

The fixes are to introduce synchronization: mutexes, std::atomic, or std::thread join edges. The interview answer: a data race is UB — possible memory corruption, wrong results, or crashes, with no automatic fix.

Answer:

Undefined Behavior — the program may corrupt memory, compute wrong results, or crash; there is no defined outcome.

A data race is: two threads access the same memory location concurrently, at least one access is a write, and there’s no synchronization between them (no mutex, atomic, or happens-before edge). In the C++ memory model this is explicitly undefined behavior:

  • The compiler may reorder or eliminate the racing accesses based on the assumption of no race.
  • The observed result can differ run-to-run, including corrupted values and crashes.
  • Nothing “fixes” it automatically — the runtime won’t upgrade conflicting accesses to atomics.

The fixes are to introduce synchronization: mutexes, std::atomic, or std::thread join edges. The interview answer: a data race is UB — possible memory corruption, wrong results, or crashes, with no automatic fix.

My Private Notes

Notes are auto-saved locally to this device.