Two threads each run counter++ a million times. You expect two million. You get 1,834,221, and a different wrong number on the next run. That gap is the whole subject in one example. counter++ is three machine operations, not one, and nothing stops the second thread from reading a stale value in the middle of the first thread’s work. If you can say why the count is wrong and name two correct fixes, you are already past most candidates.
Concurrency questions are where interviewers separate people who have shipped threaded code from people who have read about it. The vocabulary below is what a mid-to-senior round expects you to use precisely, whether the role is general backend or low-latency systems.
Why counter++ loses count
Written out, the increment is a read, an add, and a write:
reg = counter // read the current value
reg = reg + 1 // add one in a register
counter = reg // write it back
Thread B can read counter after A reads it but before A writes back. Both compute the same new value. Both store it. Two increments happened; the counter moved by one. This is a data race: two threads touch the same location, at least one writes, and there is no ordering between them. The result depends on timing, so it changes run to run and usually looks fine on your laptop and fails in production under load. Go ships a race detector for exactly this, and running the test suite with go test -race is the kind of specific answer that lands well.
Atomicity, visibility, and ordering are three separate problems
Most people collapse thread safety into one idea. Interviewers pull it apart into three, and a good answer keeps them distinct.
Atomicity is the increment problem: a read-modify-write has to happen as one indivisible step so no thread can wedge itself into the middle. Visibility is different. A write by one thread can sit in a store buffer or a register and never reach another thread’s view of memory. A plain status flag set by thread A can spin thread B forever, because B keeps reading its own cached copy. Ordering is the third: compilers and CPUs reorder instructions for speed, so the order another thread observes is not the order you wrote.
The fix has a different name in each language, and the names trip people up. Java’s volatile is a memory barrier: it gives you visibility and ordering, but not atomicity, so a volatile int still loses increments. C and C++ volatile is unrelated to threads; it only tells the compiler not to optimize away a read, and buys you no thread safety at all. For atomicity you reach for real atomic types: java.util.concurrent.atomic, C++ std::atomic, Rust’s std::sync::atomic, Go’s sync/atomic. C++ makes the ordering explicit through memory_order, from relaxed (atomic, but no ordering promises) up to seq_cst (the safe default). If you take one thing into the room: visibility and atomicity are separate problems, and a keyword that fixes one may not touch the other.
Picking the right lock
A mutex is the default answer and usually the correct one. One holder at a time, everyone else blocks. An uncontended mutex is cheap, on the order of tens of nanoseconds; a contended one that puts a thread to sleep costs a system call and a context switch, which is where lock contention actually hurts.
A spinlock busy-waits instead of sleeping. It wins only when the critical section is a few instructions and the holder is running on another core right now, so spinning briefly beats the cost of a context switch. On a single core, or around anything that might block, a spinlock is a way to burn a CPU doing nothing. A reader-writer lock lets many readers in together or one writer alone, which pays off for read-heavy data, at the risk of starving writers if readers never drain. A reentrant lock lets the same thread reacquire a lock it already holds, which matters the moment one locked method calls another.
The reason to know all four is that the follow-up is always “why that one,” and “a mutex, because the critical section is short and contention is low” is a real answer. “A lock” is not.
Deadlock is a lock-ordering bug wearing a costume
The textbook version is two account transfers running at once:
// Thread 1: transfer A -> B
lock(A); lock(B);
// Thread 2: transfer B -> A
lock(B); lock(A);
Thread 1 holds A and waits for B. Thread 2 holds B and waits for A. Neither lets go. Coffman’s four conditions all have to hold for this to happen: mutual exclusion, holding one resource while waiting for another, no way to forcibly take a lock back, and a circular wait. Break any single one and the deadlock cannot form. In practice you break the circular wait by imposing a global lock order, acquiring locks in a fixed sequence no matter the direction of the transfer:
first, second = order_by_id(A, B) // always the lowest id first
lock(first); lock(second);
The other fix is a tryLock with a timeout: if you cannot get the second lock, release the first and retry instead of waiting forever. Name livelock as the trap that lurks behind that fix, two threads that keep releasing and retrying in lockstep and make no progress, politely stuck.
Compare-and-swap and the ABA trap
Atomic counters and every lock-free structure sit on one instruction: compare-and-swap. Read a value, compute the next one, and swap it in only if the location still holds what you read. If someone changed it first, you lost the race and retry.
do {
old = counter.load()
next = old + 1
} while (!counter.compare_and_swap(old, next))
That loop is a correct, lock-free increment. It also hides the classic interview follow-up: the ABA problem. Compare-and-swap checks that the value is unchanged, not that nothing happened. If another thread moves the value from A to B and back to A, your CAS sees A and succeeds as if nothing changed, even though the world moved underneath you. This bites hardest with pointers in lock-free stacks and queues, where the reused address is a freed-then-reallocated node. The standard fix is a version tag stored next to the value, so A-with-tag-1 and A-with-tag-3 no longer compare equal.
The judgment interviewers listen for: lock-free is not a synonym for fast. It means no thread can block another, which matters for real-time and latency-sensitive paths. Under heavy contention a CAS retry loop can spin more than a mutex would ever sleep. Reach for it once you have measured a lock as the bottleneck, not reflexively.
Producer/consumer without the footguns
A bounded queue between producers and consumers is the concurrency question that shows up in nearly every language’s standard library, and interviewers still want you to build one by hand. The shape is a mutex plus two condition variables, one for “not full” and one for “not empty.”
// consumer
lock(m)
while (queue.empty()) // while, never if
wait(not_empty, m) // releases m while asleep, reacquires on wake
item = queue.pop()
signal(not_full)
unlock(m)
Two details are where the points are. You wait in a while loop, not an if, because a thread can wake without the predicate being true (a spurious wakeup, and also the case where another consumer grabbed the item first). And the condition variable atomically releases the mutex as it puts the thread to sleep, which is what lets a producer take the lock and signal. Get either wrong and the queue wedges or corrupts under load, always intermittently, always right before a demo.
The primitives on one page
| Primitive | What it guarantees | Blocking behavior | Reach for it when |
|---|---|---|---|
| Mutex | Exclusive access, one holder at a time | Blocks and sleeps waiters | Default choice for short critical sections and general shared state |
| Spinlock | Exclusive access, one holder | Busy-waits, no sleep | Multicore, critical section a few instructions, lock never held long |
| Reader-writer lock | Many readers together or one writer alone | Blocks; can starve writers | Read-heavy shared data with rare writes |
| Semaphore | Up to N permits held at once | Blocks when no permit is free | Capping concurrency, such as a pool of N connections or slots |
| Condition variable | Wait for a predicate, paired with a lock | Blocks until signaled; wake inside a while loop | Producer/consumer and any “wait until state changes” |
| Atomic / CAS | Indivisible read-modify-write, no lock taken | Non-blocking; retries on conflict | Counters, flags, and lock-free structures under measured contention |
What they actually ask
The wording is usually plainer than the theory suggests. A round picks a few of these and pushes on the “why”:
- “Two threads increment a shared counter. Walk me through what can go wrong and fix it three different ways.”
- “What’s the difference between a mutex and a semaphore, and when would you actually pick a semaphore?”
- “Here’s a function that deadlocks sometimes. Find it and fix it without a single global lock if you can.”
- “What does volatile guarantee in this language, and what does it not?”
- “Design a thread-safe bounded queue. Now make it block instead of spinning.”
The move that reads as experience is reaching for the simplest correct thing. A plain mutex around the whole critical section, shipped and then measured, beats a clever lock-free queue carrying an ABA bug you will be debugging at 2 a.m. off a stack trace that points nowhere useful. Good interviewers know that ordering. Show them you know it too, and the primitives above are enough to carry the round.
Drill the patterns next:
