Deadlock, Livelock & Starvation

Lock Ordering

The practical answer to deadlock, and it fits in one sentence: whenever two accounts must be locked, lock the lower id first. The cycle is not detected or escaped — it becomes impossible to construct, in every schedule, for free.

▶ Run the lab

The question this answers

The question

How do I make circular wait unconstructable rather than merely unlikely?

The work

Concurrent transfer(from, to, amount) calls between arbitrary pairs of accounts, where any thread may be asked for any pair in either direction.

What is shared

One mutex per account, keyed by account id. The set of locks is unbounded and chosen at runtime by the request — which is exactly the case people assume ordering cannot handle.

The invariant — what must stay true under every interleaving

The sum of the two balances is unchanged by a transfer, and no balance goes negative — which requires both accounts to be locked across the read-modify-write. On top of that, a second, structural invariant: at any instant, every thread holding two account locks holds them in ascending id order. The first invariant is the reason for the locks; the second is the reason there is no deadlock.

Synchronization exists to preserve this sentence. If a schedule can make it false, the code is wrong no matter which primitive it uses.

WorkCan it overlap?Can it parallelise?What is shared?What ordering?What synchronization?Where is contention?What can deadlock?What can race?What is gained?What complexity?

transfer(7, 12) against transfer(12, 7)

Here is the whole bug and the whole fix in one example. The natural way to write a transfer is "lock the source, lock the destination" — it reads well and it mirrors the domain. It is also the one formulation guaranteed to deadlock, because the source and destination swap when the transfer runs the other way, and two threads moving money in opposite directions between the same pair take the locks in opposite orders.

The trace below is the failure. Watch the state column: after step 2 the two locks are held by different threads, and after step 4 the wait edges close. Nothing is wrong with either thread; the pair is wrong. This is the schedule that never appears in a test suite, because a test that transfers 7 → 12 twice cannot produce it and a test that transfers in both directions almost always serialises.

Now change one line — sort the two ids before locking — and re-run every possible schedule in your head. Both threads want L7 first. One gets it, one blocks on it. The thread that got L7 proceeds to L12 unimpeded, because a thread that does not hold L7 cannot be holding L12. There is no interleaving left that produces a cycle: the ordering does not make deadlock rare, it makes it non-existent.

Top half: the natural source-then-destination order deadlocking. The fix is to make step 2 impossible.ILLUSTRATIVE
Invariant · Every thread holding two account locks holds them in ascending id order.
#Thread A — transfer(7 → 12, 50)Thread B — transfer(12 → 7, 30)State
1lock(L7) [source]·L7=A L12=free A holds=7
2·lock(L12) [source]L7=A L12=B A holds=7 B holds=12
✕ Ordering invariant: B now holds a higher-ranked lock (12) without holding the lower one (7). A descending acquisition is in progress, and a cycle is now possible.
3lock(L12) [dest] — blocks·L7=A L12=B A state=blocked on L12
4·lock(L7) [dest] — blocksL7=A L12=B A state=blocked on L12 B state=blocked on L7
5--- with ordering: lock(min(7,12)) = lock(L7)·L7=A L12=free
6·lock(min(12,7)) = lock(L7) — blocksL7=A L12=free B state=blocked on L7
7lock(L12) — acquired, no contention·L7=A L12=A
8move 50, unlock L12, unlock L7·L7=free L12=free acct 7=50 acct 12=150
9·wakes with L7, locks L12, moves 30, unlocks bothacct 7=80 acct 12=120
The fix is not a new primitive or a retry. It is the observation that "source first" is a *data-dependent* order and therefore not an order at all. Replacing it with a data-independent rank makes the descending acquisition in step 2 unwritable.

The rank removes the edge that closes the cycle

It is worth seeing why ordering works in graph terms, because that is what makes it generalise beyond two locks. Give every lock an integer rank. A thread that obeys the rule only ever has wait edges pointing from a lower-ranked held lock to a higher-ranked requested one. Every edge in the wait-for graph therefore points "up", and a cycle requires at least one edge pointing down. No down edges, no cycles — for any number of threads and any number of locks.

That argument is why the rule scales. Deadlock reasoning normally does not compose, but ranking does: a new lock is safe if you can place it in the existing hierarchy and every acquisition respects it. Whole subsystems can be given rank bands — "all cache locks are rank 100–199, all persistence locks are 200–299, never take a cache lock while holding a persistence lock" — and the property holds across teams that never talk to each other.

The graph below shows both states. On the left the descending edge exists and the cycle is live. On the right, B is waiting on L7 while holding nothing, so its only edge points up, and the cycle has nowhere to close. Note that ordering does not reduce *waiting* at all — B still waits, and lock-wait metrics look identical. It converts an unbounded wait into a bounded one, which is the entire difference between a hang and some contention.

Under ordering, B holds nothing while it waits, so there is no edge from L12 back to B and the cycle cannot close.ILLUSTRATIVE
● Thread A — holds L7, wants L12● Thread B — holds nothing, wants L7▢ Mutex: account 7 (rank 7)▢ Mutex: account 12 (rank 12)
Mutex: account 7 (rank 7)waits forThread A — holds L7, wants L12· held by
Thread A — holds L7, wants L12waits forMutex: account 12 (rank 12)· waits for — rank 7 → 12, ascending
Thread B — holds nothing, wants L7waits forMutex: account 7 (rank 7)· waits for — holds nothing

Deriving a rank when the locks are dynamic

The objection to ordering is always the same: "our locks are created at runtime, we cannot rank them." Account locks are the canonical case — millions of them, chosen per request. The answer is that you almost never need a *registry* of ranks; you need a total order over lock identities, and identities always have one.

Sort by the natural key when there is one — account id, user id, partition number, file path. Sort by object address when there is not; std::lock and most hierarchy checkers do exactly this, and it is a legitimate total order as long as the objects outlive the acquisition. Where an object is relocatable, give each one a monotonically increasing sequence number at construction and sort on that.

Two details make or break the implementation. First, the *self-transfer* case: transfer(7, 7) with a non-reentrant mutex self-deadlocks instantly, and this is a real production bug, not a hypothetical. Second, the rule must apply at the acquisition site, not the domain site — the code must lock in id order and then apply the debit and credit in business order, which is a small and slightly awkward decoupling that reviewers try to "clean up" back into a bug.

1void transfer(Account& from, Account& to, Money amount) {
2 if (&from == &to) return; // self-transfer: non-reentrant mutex
3 // would deadlock on the second lock
4
5 // Acquire in rank order, never in business order.
6 Account& first = (from.id < to.id) ? from : to;
7 Account& second = (from.id < to.id) ? to : from;
8 std::scoped_lock guard(first.mtx, second.mtx);
9
10 // Apply in business order. This asymmetry is the point: the lock order
11 // is a global structural property, the debit/credit order is domain logic.
12 if (from.balance < amount) throw InsufficientFunds{};
13 from.balance -= amount;
14 to.balance += amount;
15}
16
17// std::scoped_lock over two mutexes is itself deadlock-free (it uses a
18// try-and-back-off protocol), so this example is belt and braces. The
19// explicit ordering still matters: it is the rule the rest of the
20// codebase must follow when the two acquisitions are not adjacent.
Lock in rank order, apply in business order, and handle the self-transfer that bites everyone once.

Key points

  • "Lock the source, then the destination" is not an order — it depends on the arguments, so opposite calls take opposite orders.
  • A rank makes every wait edge ascending; a cycle needs a descending edge, so no cycle can exist for any thread or lock count.
  • The rule composes across teams and subsystems in a way no other deadlock technique does — assign rank bands and the property holds globally.
  • Dynamic locks are rankable: sort by natural key, by stable object address, or by a construction sequence number.
  • Ordering does not reduce waiting. It converts an unbounded wait into a bounded one, which is the whole difference between a hang and contention.

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.

How it works
  • Assign every lockable resource a rank that is independent of the operation being performed — an id, an address, a band per subsystem.
  • At every site that acquires more than one lock, sort the set by rank and acquire strictly ascending.
  • Handle the degenerate case where two "different" resources are the same object, which self-deadlocks a non-reentrant mutex.
  • Apply the business operation in business order after all locks are held; the two orders are deliberately decoupled.
  • Enforce with a debug-build hierarchy checker that records the highest rank each thread holds and asserts monotonicity on acquire.
Interleavings that matter
  • Unordered, opposite directions: A locks L7, B locks L12, A waits L12, B waits L7 — deadlock on the fourth step.
  • Unordered, same direction: A locks L7, B blocks on L7, A locks L12, A completes, B proceeds. Correct — which is why same-direction load tests pass.
  • Ordered, opposite directions: both threads request L7 first; the loser holds nothing while waiting, so it contributes no edge that could close a cycle.
  • Ordered, three-way: A wants {7,12}, B wants {12,19}, C wants {19,7}. Under ranking, A and C both queue on L7 and B on L12; no descending edge exists, so the classic three-cycle also cannot form.
  • Ordered but violated once: a new applyFee(account, feeAccount) path locks the fee account first because it is "always the same account". That single descending acquisition restores the full deadlock risk — the property is all-or-nothing.
What it guarantees — and does not
  • Guarantees that no cycle can exist among ranked locks, in every schedule — a stronger guarantee than anything detection or timeouts can offer.
  • Guarantees nothing about locks outside the ranking: a mutex inside a logging library or a framework callback invoked while you hold an account lock is unranked and can still close a cycle.
  • Does not guarantee fairness. The thread that loses the race on L7 may lose repeatedly; see Fairness and Starvation.
  • Does not guarantee bounded latency. A slow holder still blocks every waiter, and if enough threads queue on one popular account you get a convoy — see Lock Convoys.
  • Does not guarantee correctness of the operation. Locking in id order and then applying a debit to the wrong account is a bug the ordering cannot see.
Where contention appears
  • Ordering does not change contention at all: the same threads wait on the same locks for the same durations. It changes only whether the wait ever ends.
  • A hot account (a house account, a fee account, a settlement account) becomes a global serialisation point regardless of ordering, because every transfer touching it queues on the same mutex.
  • The rank itself can create a hot spot if you rank by something correlated with traffic — ranking by "always take the ledger lock first" means the ledger lock is acquired earliest and held longest by every thread.
  • Sorting two ids costs a comparison. That is the entire runtime overhead of the technique.
How it fails
  • Self-deadlock on transfer(x, x) with a non-reentrant mutex — a cycle of length one, and one of the most common real bugs in this pattern.
  • A single unordered acquisition path anywhere reintroduces the full risk; there is no partial credit.
  • An unranked lock acquired inside a callback or a destructor while ranked locks are held.
  • Order inverted by refactoring: someone extracts a helper that acquires internally, and the acquisition order becomes invisible at the call site.
  • Starvation of a thread that repeatedly loses the race for the first lock, which ordering does nothing to prevent.
When it helps
  • Whenever two or more locks must be held simultaneously to preserve an invariant that spans them — the transfer case, and anything shaped like it.
  • In large codebases, because it is the only prevention technique whose correctness argument survives being split across teams and files.
  • When the lock set is dynamic and unbounded, where registries and all-at-once acquisition are impractical but a natural key ordering is trivial.
When it hurts
  • When the natural rank forces an awkward hold pattern — sorting by id may mean acquiring the lock you need last, first, and holding it longer than necessary.
  • When it is used to justify keeping multi-lock code that should have been restructured into two sequential critical sections. Ordering makes bad locking safe, not good.
  • When third-party code participates in the acquisition, because the rank space is not closed and the guarantee silently becomes a hope.
How you would know
  • A debug-build lock hierarchy checker: store the maximum rank held per thread in thread-local storage and assert on every acquire that the new rank is strictly greater. One CI failure finds every inversion.
  • ThreadSanitizer's deadlock detector (--detect_deadlocks) and Boost's lock_error style hierarchy mutexes find inversions from a single non-deadlocking execution — you do not need the bad schedule to occur.
  • Grep-level audit: every call site that acquires two locks should have a visible sort. A site that does not is either wrong or has the sort hidden in a helper, and both are worth a comment.
  • Lock-wait p99 per account id, to find the hot account that ordering will not help with.
  • Zero deadlock incidents is not evidence. Absence of a deadlock proves nothing about whether the ordering holds — only the checker does.
Complexity it introduces
  • The runtime complexity added is a comparison. The human complexity is a convention that must be documented, taught, and re-checked on every review of multi-lock code.
  • Decoupling acquisition order from business order makes the code slightly harder to read, and that awkwardness is a recurring target for "simplifying" refactors that reintroduce the bug.
  • Rank bands across subsystems require a written hierarchy that someone owns, or it becomes folklore.
  • The checker itself is real code — small, but it must run in CI to be worth anything, and it changes lock acquisition on the hot path in debug builds only.
Simpler alternatives
  • Restructure to hold one lock at a time. If the invariant does not truly span both accounts, two sequential critical sections beat any ordering scheme. See Finding the Critical Section.
  • A single lock over the account table when contention allows it — no order needed because there is no pair.
  • A database transaction with row locks, which handles ordering and detection for you and gives a retryable error on conflict. See Locks and Deadlocks.
  • std::scoped_lock with multiple mutexes, which is deadlock-free within one acquisition without you specifying an order — useful, but it does not help when the two acquisitions are in different functions.
  • Route all transfers for an account through a single owner keyed by id (an actor or a partitioned queue), removing simultaneous acquisition entirely. See The Actor Model.

A global lock order is a proof, not a habit

A global lock order is a proof, not a habit
The same tasks and the same locks. Turn on the convention and every reachable schedule is checked — not sampled — for a wait-for cycle.
T1
T2
Effective acquisition order
T1: Lock A → Lock B
T2: Lock B → Lock A
Exhaustive check
reachable states
20
states with a cycle
1
verdict
deadlock reachable
deadlock-free states19 · turn the convention on to compare
● Task 1● Task 2▢ Lock A▢ Lock B
Cycle: Task 1 → Lock B → Task 2 → Lock A → Task 1
One of the reachable cycles, found by walking every schedule rather than by waiting for it to happen in production.
1 of the 20 reachable states contain a wait-for cycle. Your tests explore this space at random and mostly miss it — which is the whole difficulty of deadlock: the failing schedules are rare, not impossible, and they get rarer as the machine gets faster. The price is real: a global order means the code that needs B first must still take A first, which sometimes forces you to hold a lock longer than the work requires, or to look up data before you know you need it. Deadlock avoidance costs contention. It is still the cheapest of the options, because the alternatives — lock timeouts with retry, or a watchdog that kills a participant — turn a hang into a partial failure you now have to handle.
SIMPLIFIEDExhaustive over this machine's reachable states. A real program has more state; the argument, not the state count, is what transfers.

Build a deadlock yourself

Build a deadlock yourself
Each task takes two locks and holds them until it is done. Choose the order each one uses, then decide who runs next. Nothing is scripted — if it deadlocks, you scheduled it.
T1
T2
2 steps
Task 1 (A→B)
holds Lock A
Task 2 (B→A)
holds Lock B
● Task 1● Task 2▢ Lock A▢ Lock B
Lock Awaits forTask 1· held by
Lock Bwaits forTask 2· held by
2 steps in. Two tasks are taking the same pair of locks in opposite orders. That is not yet a deadlock — it is the *possibility* of one, which is why this bug passes tests for months. To realise it, give each task one lock and then make each ask for the other.
SIMPLIFIEDBlocking acquisition, no timeouts, no try-lock. Those are exactly the escape hatches that turn this hang into a retry.

The lost update, step by step

The lost update, step by step
One fixed schedule of two concurrent increments. Nothing to choose — watch where the invariant dies, and where the cause actually was.
1/6 · A · rA ← counter
Invariant · after k completed increments, counter === k. No update is lost.
#Task A — counter++Task B — counter++State
1rA ← counter·counter=0 rA=0 rB=—
2·rB ← countercounter=0 rA=0 rB=0
3rA ← rA + 1·counter=0 rA=1 rB=0
4counter ← rA·counter=1 rA=1 rB=0
5·rB ← rB + 1counter=1 rA=1 rB=1
6·counter ← rBcounter=1 rA=1 rB=1
✕ 2 increments completed, counter = 1
step
1 of 6
counter
0
increments completed
0
invariant
holds
A reads 0. Correct at this instant, and about to stop being correct. A read-modify-write is a window, not an instant. It stays open from the read to the write.
SIMPLIFIEDOne of twenty possible interleavings of this program, chosen because it fails.

What people believe, and what is true

Claim

Our locks are created dynamically, so we cannot order them.

Reality

You need a total order over identities, not a static registry. Account id, user id, partition number, stable address or a construction counter all supply one, and sorting two of them costs a comparison.

Claim

Locking the source first is a consistent order.

Reality

It is consistent within one call and inverted between calls, which is the definition of an inconsistent order. Any rule that depends on the operation's arguments is not an order.

Claim

Ordering makes the lock contention go away.

Reality

It changes nothing about how long threads wait. A hot account still serialises every transfer that touches it; ordering only guarantees the wait terminates.

Go deeper

Overview

Always take the lower account id first. Two transfers in opposite directions then both queue on the same lock instead of each holding what the other wants.

Practical

Sort the lock set at the acquisition site, apply the business operation afterwards, and guard the self-transfer case. Then add a debug-build hierarchy checker, because the convention is only as good as the enforcement.

Advanced

Ranking is the only deadlock technique that composes. Assign rank bands per subsystem — cache below persistence below external — and independent teams preserve global acyclicity without coordination. The rule's weakness is closure: an unranked lock inside a callback breaks it, which is why "no unknown calls under a lock" is its necessary companion.

Internals

Kernels and database lock managers use the same idea under different names — lock hierarchies, latch ordering, intent locks arranged by level. Postgres additionally detects rather than only prevents, because a transaction can be rolled back; see The Lock Manager. The choice between preventing and detecting is really a choice about whether your state has an undo.

Apply it