The question this answers
When is it right to make everyone else wait rather than let them race and retry?
Decrementing the remaining seat count for a concert with 40 seats left and 3,000 people clicking "buy" in the same eight seconds.
The seat inventory row, held under an exclusive lock for the duration of the read-check-write. While the lock is held, no other buyer can observe or modify it.
Seats sold never exceeds seats available, and every buyer who receives a confirmation holds a seat that no other buyer holds.
Synchronization exists to preserve this sentence. If a schedule can make it false, the code is wrong no matter which primitive it uses.
Prevention instead of detection
Optimistic control lets everyone proceed and sorts it out at commit. Pessimistic control refuses to let the situation arise: the first arrival takes exclusive ownership of the row, and everybody else queues. Nothing is discarded, nothing is recomputed, and the sequence of events is a straight line instead of a set of possible interleavings.
That is a real correctness advantage and it is usually undersold. With a lock held across the read-check-write, the check-then-act window closes entirely — the classic "read 1 seat remaining, both threads decide yes, both write 0" cannot occur, because the second reader does not get to read until the first writer has finished. See Finding the Critical Section for how small that region should be, and Mutexes: What They Protect and What They Do Not for the primitive.
What you buy it with is waiting. 3,000 buyers serialize through one row, and if the critical section is 200 microseconds the queue drains in under a second; if it is 40 milliseconds because someone put a payment-provider call inside the lock, the queue takes two minutes and every connection in the pool is holding one. The lock is not the cost — the lock *hold time* is the cost, multiplied by the arrival rate.
| # | Buyer 1 | Buyer 2 | State |
|---|---|---|---|
| 1 | read seats_left -> 1 | · | seats_left=1 |
| 2 | · | read seats_left -> 1 | seats_left=1 |
| 3 | check 1 > 0, write seats_left = 0, confirm | · | seats_left=0 sold=1 |
| 4 | · | check 1 > 0 (from its stale read), write seats_left = 0, confirm | seats_left=0 sold=2 ✕ Two confirmations for one seat. The write did not conflict; the *decision* was made on state that had already changed. |
| 5 | --- rerun with SELECT ... FOR UPDATE --- | · | seats_left=1 sold=0 |
| 6 | lock row, read seats_left -> 1 | · | seats_left=1 lock=B1 |
| 7 | · | lock row -> blocked | lock=B1 b2=waiting |
| 8 | write seats_left = 0, commit, release | · | seats_left=0 sold=1 lock=free |
| 9 | · | acquires lock, reads seats_left -> 0, rejects | seats_left=0 sold=1 |
Hold time is the whole bill
Everything that goes wrong with pessimistic control goes wrong because the lock is held longer than it needs to be. A 200-microsecond critical section under 3,000 arrivals per second is a mild queue. The same section with a 40-millisecond HTTP call inside it is a 200x regression, a saturated connection pool, and an incident — and the code diff that caused it is one line moved inside a block.
The rules follow directly. Never hold a lock across I/O: no network call, no disk write you can defer, no waiting on another lock you do not control. Acquire as late as possible and release as early as possible. Lock the narrowest thing that preserves the invariant — the row, not the table; the shard, not the map.
The second-order effects deserve names because they show up as mysteries rather than as lock metrics. A convoy forms when the queue never drains, so every arrival waits behind an unchanging backlog and throughput pins to one-critical-section-per-unit-time regardless of core count (Lock Convoys). Priority inversion appears when a low-priority holder is preempted while high-priority waiters queue behind it. And nested locks acquired in inconsistent order give you a deadlock cycle instead of a queue (Lock Ordering).
Which lock, and at what granularity
"Take a lock" is not one decision. The granularity determines how much concurrency survives, the mode determines whether readers block each other, and the scope determines whether you can deadlock at all. Getting the granularity wrong is the single most common way a pessimistic design underperforms an optimistic one for no correctness benefit.
The default should be the narrowest lock that still covers the invariant, and the invariant is what sets the floor. If the rule is "seats sold never exceeds seats available for this event", the event row is the right granularity and a table lock is a 40x concurrency loss for nothing. If the rule spans two rows — a transfer that must keep the sum constant — then one row lock is not enough and you have a lock-ordering problem to solve.
One boundary is worth stating flatly: a process-local mutex protects nothing once a second process runs the same code. The invariant "seats sold never exceeds seats available" is a property of the shared database, so the lock must live there — a row lock or a conditional update — not in your application memory. See A Mutex on Server A Does Nothing About Server B.
| Lock | Concurrency preserved | Right when | Wrong when | Characteristic failure |
|---|---|---|---|---|
| Row / key lock (SELECT ... FOR UPDATE) | High — different keys never interact | The invariant is per entity | The invariant spans entities | Deadlock when two transactions lock two rows in opposite order |
| Process-local mutex | High within one process, zero across processes | The state lives only in this process | The state is in a shared store | Silently useless as soon as you run two replicas |
| Read-write lock | High for readers, none during a write | Reads vastly outnumber writes and are slow | Reads are short — the lock overhead exceeds the work | Writer starvation under a steady reader stream |
| Table / global lock | None | Genuinely global invariants; schema changes | Almost always | One lock serializes the entire service |
| Advisory / named lock in a shared store | Per-name | Coordinating processes with no natural row to lock | The holder can die without releasing | Orphaned lock with no owner until a lease expires |
Key points
- Pessimistic control prevents conflicts rather than detecting them: the loser waits instead of doing work it will discard.
- The lock must span the whole read-check-write, not just the write — otherwise the decision is made on state that can change under it.
- Lock hold time multiplied by arrival rate is the entire cost model. Holding a lock across I/O is the canonical way to destroy a service.
- Granularity is a design decision, not a default: lock the narrowest thing that still covers the invariant.
- A process-local mutex is meaningless for state shared between processes — the lock has to live where the state lives.
The loop, answered
Every field is required, which is why no lesson here can recommend concurrency without naming the interleaving that breaks it, the complexity it adds, and the simpler thing to consider first.
- • Acquire exclusive ownership of the resource the invariant is defined over — a row lock, a mutex, an advisory lock keyed by entity id.
- • Read the state. Because the lock is held, this state cannot change beneath the decision you are about to make.
- • Check the condition and perform the write. Everything between acquire and release is one indivisible step from every other participant's point of view.
- • Release, either explicitly or by transaction commit. Waiters are woken and one of them acquires next.
- • Waiters are queued by the lock implementation, so cost is paid as latency rather than as discarded work — and the queue length is arrival rate times hold time.
- • Unlocked check-then-act: B1 reads 1, B2 reads 1, B1 writes 0 and confirms, B2 writes 0 and confirms — two confirmations for one seat.
- • Locked: B1 acquires, reads 1, writes 0, releases; B2 acquires, reads 0, rejects — correct under every arrival order.
- • Lock too narrow: B1 reads 1 (no lock), then locks only for the write; B2 does the same — the lock made the writes orderly and the oversell still happened, because the read was outside it.
- • Lock held across I/O: B1 acquires, calls the payment provider (40 ms), releases; B2, B3 and B4 block for 40, 80 and 120 ms respectively — throughput is now one buyer per provider round trip.
- • Inconsistent order: T1 locks account A then account B; T2 locks account B then account A — neither can proceed and the database kills one after its deadlock timeout. See Lock Ordering.
- • Holder dies: a process holding an advisory lock is killed; without a lease or session-scoped release, every other participant waits forever.
- • Guarantees mutual exclusion over the region the lock covers, so the read and the write see the same state.
- • Guarantees that the loser does no wasted work — it waits, then acts on current state.
- • Guarantees bounded, predictable per-operation latency at a given contention level, which optimistic control does not.
- • Does NOT guarantee anything outside the locked region. A lock around the write but not the read leaves the check-then-act window wide open.
- • Does NOT guarantee freedom from deadlock. Two locks acquired in inconsistent order deadlock regardless of how correct each individual lock is.
- • Does NOT guarantee fairness. Most mutexes make no ordering promise, so a waiter can be repeatedly overtaken — see Fairness and Starvation.
- • Does NOT survive a process boundary if the lock is process-local, or a holder crash if the lock has no lease.
- • All contention is explicit and measurable: waiters, wait time, and hold time are all directly observable, which is a genuine operational advantage over optimistic retries.
- • Queue length is arrival rate times hold time. Doubling hold time doubles the queue at constant traffic.
- • A lock held across I/O converts a microsecond critical section into a millisecond one and multiplies the queue by three orders of magnitude.
- • Convoys form when the queue never empties, pinning throughput to one critical section per unit time no matter how many cores exist. See Lock Convoys.
- • Waiting threads and their connections are still resources — a blocked worker holds its request slot, its stack and its database connection the whole time.
- • Deadlock from inconsistent lock ordering across two or more resources.
- • Convoy: sustained queueing that removes all parallelism from the locked path.
- • Priority inversion, where a low-priority holder is preempted while high-priority work queues behind it.
- • Starvation of a waiter that is repeatedly overtaken by newly arriving acquirers under a barging (non-FIFO) lock.
- • Pool exhaustion: blocked workers hold connections, so a slow critical section drains the connection pool before it drains the queue.
- • Orphaned lock when the holder crashes and the lock has no lease or session-scoped release.
- • A locked write path with an unlocked read path, which preserves the illusion of safety and none of the substance.
- • High contention on a single key, where optimistic retries would collide constantly: inventory, seat counts, balances, quota counters.
- • When a conflict is expensive to undo — anything with a side effect, a charge, or an external notification attached.
- • When the critical section is genuinely short, so the queue drains faster than it fills.
- • When predictable latency matters more than peak throughput; waiting is bounded and measurable, retrying is neither.
- • When the invariant spans several reads and writes that must see a consistent world, which no single conditional write can express.
- • Long think times, especially human ones — a lock held while a user fills in a form is a lock held for minutes, and possibly forever if they close the tab.
- • Across a network boundary, where the holder may vanish and the waiters have no way to know.
- • Low contention, where the lock costs coordination on every operation to prevent a conflict that essentially never happens.
- • Read-heavy workloads under an exclusive lock, where readers block readers for no invariant-related reason.
- • Any path where the critical section contains I/O that cannot be moved out.
- • Lock hold time distribution, not the mean — the p99 hold time is what sets the queue during the incident.
- • Lock wait time and the number of waiters, which together tell you whether you have a queue or a convoy.
- • Lock acquisitions per second per key, to find the hot key before it finds you.
- • Deadlock or lock-timeout counts from the database, which are usually already emitted and rarely graphed.
- • Connection-pool utilization on the locked path, because pool exhaustion typically shows up before lock metrics do. See Pool Saturation.
- • Lock ordering becomes a global property of the codebase: every path that takes two locks must take them in the same order, and nothing in the type system enforces it.
- • Every lock needs an owner, a documented scope and a guaranteed release path, including on the exception path.
- • Timeouts and lease durations have to be chosen, and both a too-short and a too-long value cause distinct production failures.
- • Locks make code non-composable: a function that takes a lock cannot be safely called from another that already holds one unless you know the order.
- • Testing under contention is hard, so the deadlock the design permits is usually discovered in production.
- • Optimistic concurrency control, when conflicts are rare and think time is long. See Optimistic Concurrency Control and Optimistic vs Pessimistic.
- • A single atomic, commutative statement:
UPDATE seats SET left = left - 1 WHERE id = ? AND left > 0needs no lock and no version, because the condition and the write are one indivisible operation. - • Serialize through a queue or a per-entity actor so writes to one key are ordered by construction, converting a lock into a mailbox. See The Actor Model.
- • Shard the contended key so that most operations touch different locks.
- • Immutability or copy-on-write, when the state is read-dominated and the lock is mostly protecting readers from a rare writer. See Copy-on-Write as a Concurrency Strategy.
How much of the task is inside the lock?
What people believe, and what is true
Locking the write is enough.
The oversell happens in the *decision*, not the write. If the read that informed the decision was outside the lock, the state can move between them and the lock bought you nothing but overhead.
Pessimistic locking is old-fashioned and slow.
At high contention on a single key it does strictly less total work than optimistic retries, because nothing is computed twice. It is slow when hold times are long, which is a property of your critical section, not of locking.
A mutex in the application protects the row in the database.
Only until the second replica starts. The lock must live at the same scope as the state it protects. See A Mutex on Server A Does Nothing About Server B.
Go deeper
Overview
Take exclusive ownership before you read, keep it while you decide, release after you write. Everyone else queues, and nobody wastes work.
Practical
Span the whole read-check-write. Never call out over the network inside the lock. Lock the row rather than the table. Always release on the exception path.
Advanced
Model the queue: arrival rate times hold time is your waiter count. Then look for the cheaper shapes — a conditional single-statement update, a sharded key, or a per-entity queue often removes the lock entirely.
Internals
A contended lock generally parks the waiter, which is a context switch through the kernel scheduler and a loss of cache warmth. That is why very short critical sections are sometimes better served by brief spinning — see Spin Locks — and why hold time interacts with scheduling rather than just with arithmetic.