Senior Engineering Interview Handbook / Chapter 63
Concurrency and Synchronization
A practical model for reasoning about races, locks, semaphores, atomics, visibility, deadlocks, starvation, thread pools, event loops, async execution, and structured concurrency.
Preparing audio…
Audio edition
Concurrency and Synchronization
Page tools
When the last seat is sold twice
A ticket service has one seat left. Two requests arrive together. Each reads
remaining = 1; each decides it may sell; both create a ticket.
The arithmetic is easy. The overlap is the problem. Correctness depends on a rule that was never made indivisible:
At most one ticket may be issued for one remaining seat, and the inventory count must agree with the tickets that exist.
Concurrency is work whose lifetimes overlap. Parallel work may execute at the same instant on several cores; concurrent work need only be in progress over the same interval. A single-threaded event loop has concurrency whenever one task yields and another advances. The word says nothing about whether their shared state remains correct.
Synchronization supplies the missing rules. Before choosing a mutex, atomic, semaphore, queue, or async abstraction, finish this sentence:
The overlapping work is allowed only if ______ remains true.
The blank may contain an invariant, a capacity limit, a visibility guarantee, a condition for waking, an ordering rule, or an owned task lifetime. Those are different obligations. The primitive follows from the one you can state and prove.
Make the race visible
The unsafe ticket code looks plausible in isolation:
if seats[event_id] > 0:
seats[event_id] -= 1
tickets[user_id] = event_id
return confirmed
return sold_out
Now write the interleaving rather than describing it vaguely:
initially: seats[concert] = 1
request A reads 1
request B reads 1
request A writes 0 and records ticket A
request B writes 0 from its stale decision and records ticket B
Whether the final count becomes 0, -1, or one request overwrites another
depends on the operations and runtime. The stable fact is that two
confirmations escaped from one unit of inventory.
A race condition exists when a permitted timing changes correctness. The interleaving shows exactly where the decision stopped being safe. It also shows why making only the decrement atomic would be insufficient: the check, inventory update, and ticket creation form one invariant.
For shared memory in one process, a mutex can make that sequence indivisible:
lock event_inventory:
if seats[event_id] == 0:
return sold_out
seats[event_id] -= 1
tickets[user_id] = event_id
return confirmed
The lock protects a rule, not the variable whose name appears after lock.
Separately synchronized getters and setters would still leave the
read-check-write sequence open. A useful review question is therefore not
“Is this collection thread-safe?” but “Which operations must appear to happen
together for the invariant to survive?”
The boundary also has a location. An in-process mutex coordinates threads in one process. If several service instances sell from the same inventory, the rule must move to a shared authority: perhaps a database transaction with a conditional update, one inventory owner, or a partitioned command stream. Two correct local locks do not coordinate each other.
What a lock does—and what it costs
A lock grants temporary exclusive ownership of a critical section. It is a good default when several execution paths need direct access to shared state and the protected operation is small enough to reason about.
Correctness fixes the minimum size of the section. Latency fixes the pressure to make it smaller. Holding the ticket lock across a payment request would serialize inventory while waiting on a remote system. A slow provider could then stop every buyer, and a callback into ticket code could create a lock cycle. Reserve locally under the inventory rule, release the lock, perform remote work under a separate lifetime and capacity policy, then finalize or expire through an explicit state transition.
This is not permission to move half the invariant outside the lock. Reduce contention by changing the design: partition ownership by event, use immutable snapshots for read-mostly state, narrow the data scope, or place the invariant behind one command owner. Measure lock wait time and hold time before adding a more elaborate primitive.
Read-write locks fit a narrower case: reads dominate, writes are uncommon, and readers require a consistent view that snapshots cannot provide. They lose their appeal when “reads” lazily initialize data, update caches or metrics, or keep writers waiting indefinitely.
Visibility is a separate promise
Suppose a worker loops until another thread sets cancelled = true. A plain
shared boolean may not be a cross-thread signal. Compilers, processors, and
runtimes may reorder or cache operations within the freedoms of the
language’s memory model. One thread can keep observing stale state or see
related writes in an unintended order.
Use a documented publication mechanism: a lock, atomic value, channel, future completion, cancellation token, or another primitive defined by the runtime. The primitive supplies both an indivisible operation where promised and the necessary visibility relationship.
Atomics are well suited to state whose correctness fits in one independent operation: a metric counter, a cancellation bit, a one-time state transition, or an atomic reference swap to an immutable snapshot. They do not naturally protect “update A, inspect B, perform a side effect, then publish C.” That is a multi-part protocol. Use a lock, transaction, owner, or carefully proved algorithm rather than assembling atomics until the code merely looks lock-free.
Memory-ordering options differ by language and runtime. The honest answer is to name the guarantee required—such as publishing initialized data before a ready flag becomes observable—and use the documented ordering that establishes it. “Atomic” without a memory-model argument is not yet a proof.
Capacity is not mutual exclusion
After reserving a seat, the service calls a payment provider. The provider allows at most twenty concurrent calls from this client. That is a capacity rule, not an inventory invariant.
A semaphore can grant twenty permits. A bounded worker pool can enforce the same ceiling for a class of work. Neither says whether two callers may mutate the same ticket safely. Conversely, an inventory mutex that permits one request at a time is a poor way to express a provider’s capacity of twenty.
Every capacity limit needs an overflow policy. When all payment permits are held, does a request wait within its remaining deadline, receive a retryable response, enter durable work, or get shed? “It queues” is incomplete until the queue has a bound and the system defines what happens there.
Thread pools combine a worker limit with a queue. They fail less visibly when the workers are bounded but the queue is not. They can also deadlock when every worker submits child work to the same pool and waits for that work to run. Separate CPU-heavy and blocking I/O workloads when their saturation behavior differs, and choose pool size from CPU capacity, memory per task, downstream limits, and measurement—not from a belief that more threads imply more throughput.
Waiting needs a predicate
A ticket dispatcher may sleep until its queue contains work. With a condition variable, the shared predicate remains authoritative:
lock queue_mutex:
while queue.is_empty():
not_empty.wait(queue_mutex)
job = queue.pop()
The loop handles spurious wakeups, another worker consuming the job first, and a notification that happened before this worker began to wait. A notification means “the state may have changed,” not “your condition is now true.”
Channels, blocking queues, futures, promises, and latches package common waiting rules at a higher level. They are often easier to compose, but the same questions remain: what completes the wait, can completion be missed, what happens on closure or failure, and which deadline ends it?
Ordering can remove scattered locks
Ticket commands for separate events can run concurrently. Commands for one event need an order if they compete for the same inventory. An actor, event-loop owner, shard worker, or partitioned queue can place those commands behind one owner.
Ownership changes the proof. Instead of establishing that every caller takes the right locks, establish that every mutation reaches the same owner and that the owner processes one command at a time. The trade-off is now visible in queueing: a popular event becomes a hot key, so admission control, backlog metrics, and a failure policy matter. Per-key ordering also does not create a global order that the product never asked for.
Several locks may still be necessary. Imagine moving a reservation between two event inventories. One request locks event A then event B; another locks B then A. Each holds one lock and waits forever for the other. That is deadlock.
Acquire multiple locks in a deterministic order—such as sorted event ID—or move the combined rule behind a transaction or single owner. Also avoid holding locks across callbacks, remote calls, sleeps, or code whose lock behavior is unknown.
Livelock has activity but no progress: two workers repeatedly yield or retry in response to each other. Starvation lets some work wait indefinitely while other work repeatedly wins. Bounded retries with jitter can help livelock; fair queues, aging, quotas, or shorter critical sections can address starvation. The right metric distinguishes them: lock cycles or stuck waits, high retry counts without completion, or an old work item whose age keeps growing.
Async work still needs an owner
Async execution allows work to yield while waiting instead of occupying a thread for the entire wait. It is valuable for many concurrent sockets and remote calls. It does not make CPU work free, shared state private, queues bounded, or failures observable by itself.
An event loop works because callbacks and tasks run briefly and yield. A large parse, compression job, synchronous file call, or long callback blocks unrelated requests even when they share no application data. Move CPU-heavy work to a bounded CPU pool or job system, keep callbacks short, and observe event-loop lag alongside request latency and queue depth.
Now suppose one ticket request must check fraud, compute tax, and then obtain a payment authorization within an 800 ms deadline. Starting detached work is easy. Preserving the dependency and deciding what owns the work is the design:
with request_deadline(800ms) as scope:
fraud = scope.spawn(check_fraud())
tax = scope.spawn(compute_tax())
wait for fraud and tax
if fraud approves:
authorize_payment(price + tax)
A structured concurrency scope keeps child work within the parent’s lifetime. If one required child fails or the deadline expires, the parent cancels the rest and waits for their cleanup according to the runtime’s rules. Errors have a path back to the caller; child tasks cannot silently outlive a request and write stale results later.
The scope does not replace provider semaphores, idempotency, or transactions. It protects lifetime. Each remote call still needs its own timeout within the remaining deadline, each dependency needs a concurrency limit, and any mutation must be safe if cancellation arrives after the remote side committed but before the caller received the response.
Fire-and-forget is therefore a deployment decision, not an async convenience. Work that must outlive the request needs a durable owner, an idempotency key, a retry and expiry policy, an error sink, and observability. Otherwise it is simply orphaned work.
Put the rules on one purchase
Return to the last seat. A coherent design can now separate obligations that the first version collapsed:
- A transaction or inventory owner conditionally creates a short-lived reservation. This protects the seat invariant across service instances.
- A bounded payment path acquires provider capacity only within the request’s remaining deadline. The inventory rule is not held open during the call.
- An owned async scope joins the independent risk and tax work before payment, preserving dependencies while propagating failure and cancellation.
- A guarded state transition confirms the reservation once. Timeout and retry behavior use an idempotency key because a lost response does not prove that payment failed.
- Expiry or explicit failure releases the reservation through the same state machine. A queue may order those commands per reservation, but its lag and bounds become part of the product contract.
There is no universal primitive for this system because it has several different rules. The design becomes tractable when each rule has one owner, one boundary, and one failure policy.
Make the proof yourself
Change the original problem: two buyers each try to reserve two adjacent seats, and each request locks its first seat before attempting the second. Before looking back through the chapter, write down:
- an interleaving that deadlocks when the requests choose opposite lock order;
- the invariant that covers the pair rather than either seat alone;
- a deterministic acquisition order or single-owner alternative;
- the behavior when a buyer cannot obtain both seats before its deadline;
- the metrics that would separate contention, deadlock, starvation, and ordinary sold-out responses.
Then add a notification task that sends email after confirmation. Decide whether it belongs inside the request scope or in durable background work. If it is durable, name its owner, deduplication key, retry bound, expiry policy, and error signal. “Spawn it and return” is not an answer.
The durable reasoning sequence is short: name the overlapping work, state the rule, show the unsafe timing, place the synchronization boundary, and name the cost at saturation or failure. Once work crosses a network boundary, those same questions acquire timeouts, retries, intermediaries, and partial failure—the subject of the next chapter.
Continue reading
Full table of contents