Bank transfer — a lock-ordering deadlock and a conservation law¶
What this teaches: how locking two resources in opposite orders across concurrent actors produces a circular wait (deadlock), and how a global lock order eliminates it — together with a conservation invariant that holds across every reachable state.
The bug¶
Two transfers run concurrently:
- t1: moves a unit from account A to account B. Locks A first, then B.
- t2: moves a unit from account B to account A. Locks B first, then A.
The fatal interleaving:
t1_lock_a → t1 holds lock A
t2_lock_b → t2 holds lock B
t1_lock_b → t1 waits for B (held by t2)
t2_lock_a → t2 waits for A (held by t1)
Both transfers are now waiting for a lock the other holds. Deadlock. musil reports it with the shortest path in — four steps.
The fix: global lock order¶
Require every transfer to lock the lower-id account first, regardless of the direction of the transfer. With t2 also locking A before B:
The circular wait is structurally impossible — musil confirms no reachable deadlock.
The conservation invariant¶
Beyond deadlock, musil also verifies that money is conserved: the total a + b never changes. This invariant holds in every reachable state, including mid-transfer when one account has been debited but the other not yet credited:
This is a stronger guarantee than "no deadlock" — it says the semantics of the transfer are also correct.
Source¶
"""Bank transfer -- a lock-ordering deadlock, and the conservation law that survives the fix.
Two transfers run concurrently: one moves a unit A->B, the other B->A. Each locks its source account,
then its destination. The naive order deadlocks: the A->B transfer grabs A's lock while the B->A
transfer grabs B's lock, and now each waits forever for the lock the other holds. musil reports the
DEADLOCK with the shortest trace into it.
Locking the accounts in a fixed global order (lowest id first) breaks the circular wait. musil then
confirms two things about the fixed protocol: there is no reachable deadlock, and the conservation
invariant -- total money is never created or destroyed -- holds in every reachable state.
Run: python examples/bank_transfer.py
"""
from __future__ import annotations
from dataclasses import dataclass, replace
from musil import Action, Model, check
TOTAL = 2 # the bank starts with account a = 2, b = 0
@dataclass(frozen=True)
class Bank:
a: int = 2
b: int = 0
owner_a: str | None = None # which transfer holds account a's lock (None = free)
owner_b: str | None = None
t1: str = "idle" # transfer a->b: idle -> locked -> both -> done
t2: str = "idle" # transfer b->a
def _owner(s: Bank, acct: str) -> str | None:
return getattr(s, f"owner_{acct}")
def _set_owner(s: Bank, acct: str, val: str | None) -> Bank:
return replace(s, **{f"owner_{acct}": val})
def _phase(s: Bank, t: str) -> str:
return getattr(s, t)
def _set_phase(s: Bank, t: str, val: str) -> Bank:
return replace(s, **{t: val})
def _move(s: Bank, frm: str, to: str) -> Bank:
return replace(s, **{frm: getattr(s, frm) - 1, to: getattr(s, to) + 1})
def _release(s: Bank, t: str) -> Bank:
s = replace(s, owner_a=None if s.owner_a == t else s.owner_a)
return replace(s, owner_b=None if s.owner_b == t else s.owner_b)
def _transfer(t: str, first: str, second: str, frm: str, to: str) -> list[Action[Bank]]:
"""Transfer ``t`` locks ``first`` then ``second``, then moves a unit ``frm`` -> ``to`` and releases."""
return [
Action(
f"{t}_lock_{first}",
lambda s: _phase(s, t) == "idle" and _owner(s, first) is None,
lambda s: _set_phase(_set_owner(s, first, t), t, "locked"),
),
Action(
f"{t}_lock_{second}",
lambda s: _phase(s, t) == "locked" and _owner(s, second) is None,
lambda s: _set_phase(_set_owner(s, second, t), t, "both"),
),
Action(
f"{t}_commit",
lambda s: _phase(s, t) == "both",
lambda s: _set_phase(_release(_move(s, frm, to), t), t, "done"),
),
]
def _model(*, ordered: bool) -> Model[Bank]:
t1 = _transfer("t1", "a", "b", "a", "b") # a->b always locks a then b (source-first == ordered)
# naive: t2 (b->a) locks its SOURCE b first -> crosses with t1. ordered: t2 also locks a first.
t2 = _transfer("t2", "a", "b", "b", "a") if ordered else _transfer("t2", "b", "a", "b", "a")
return Model(
init=Bank(),
actions=[*t1, *t2],
invariants={"conservation": lambda s: s.a + s.b == TOTAL},
terminal=lambda s: s.t1 == "done" and s.t2 == "done",
)
def naive_model() -> Model[Bank]:
return _model(ordered=False)
def ordered_model() -> Model[Bank]:
return _model(ordered=True)
def main() -> int:
print("=== naive (each transfer locks its SOURCE account first) ===")
print(check(naive_model()))
print("\n=== ordered (every transfer locks the lower-id account first) ===")
print(check(ordered_model()))
return 0
if __name__ == "__main__":
raise SystemExit(main())
Run it¶
See also¶
- Dining philosophers — the same resource-hierarchy fix for forks instead of account locks.
- Lost update — a simpler concurrency bug where no lock is acquired at all.
- Mutual exclusion (Peterson) — the mutual exclusion primitive that makes atomic sections possible.