Static and Dynamic Lock Ordering — Preventing Deadlocks Between Cooperating Objects
Intro
Two goroutines need to protect two shared objects — an account in a bank transfer, a record and its index, a file descriptor and its buffer. They each acquire two locks. If one goroutine grabs lock A then B, while another grabs B then A, the classic ABBA pattern forms: each thread holds what it needs and waits for the other to release theirs. Deadlock.
Lock ordering solves this by enforcing a global acquisition order so that circular-wait can never form. There are two established approaches: static (compile-time fixed) and dynamic (runtime-determined but globally agreed).
This post walks through three Go programs — deadlock, then each fix — running them in sequence.
The Deadlock Problem
When cooperating objects have no imposed order on their locks, each goroutine makes its own choice about which lock to grab first. With two goroutines and two accounts, both choices are equally valid locally — but the global result is a permanent standstill.
The code below creates two bank accounts and fires two concurrent transfers in opposite directions:
func TransferDeadlock(from, to *Account, amount int) bool {
from.mu.Lock()
time.Sleep(50 * time.Millisecond)
to.mu.Lock()
defer from.mu.Unlock()
defer to.mu.Unlock()
if from.balance >= amount {
from.balance -= amount
to.balance += amount
return true
}
return false
}
Goroutine 1 calls TransferDeadlock(a1, a2) → grabs a1’s lock, then tries a2.
Goroutine 2 calls TransferDeadlock(a2, a1) → grabs a2’s lock, then tries a1.
Both block on the second lock. No progress possible.
Here is what happened when I ran it:
Neither goroutine printed its completion message. Both balances stayed at their initial values (a1 = 1000, a2 = 500) because neither transfer ever executed — the main goroutine waited three seconds for locks that would never be released, then printed the unmodified balances and exited.
Static Lock Ordering
Static lock ordering defines a fixed, global order at compile time and enforces it in every call site. The most common technique is comparing memory addresses: always acquire the lower address first. This works because the rule — “lower address before higher” — is identical for all callers.
func TransferStatic(from, to *AccountStatic, amount int) bool {
var first, second *AccountStatic
fromAddr := uintptr(unsafe.Pointer(from))
toAddr := uintptr(unsafe.Pointer(to))
if fromAddr < toAddr {
first, second = from, to
} else {
first, second = to, from
}
first.mu.Lock()
defer first.mu.Unlock()
second.mu.Lock()
defer second.mu.Unlock()
if from.balance >= amount {
from.balance -= amount
to.balance += amount
return true
}
return false
}
The same two accounts, the same opposite-direction transfers — but both goroutines now independently determine which address is lower and must be acquired first. The deadlock condition disappears.
Both goroutines printed their completion messages (addresses differ per run; memory allocation is non-deterministic). Both balances were updated correctly, and the total was preserved. Both finished because they agreed on who went first. Circular-wait is broken: you cannot form a cycle when every thread must acquire lock A before lock B.
Dynamic Lock Ordering
Static ordering requires knowing all resource addresses at compile time. In practice — think connection pools, dynamically allocated objects, or distributed systems where resources have IDs but not stable memory locations — you need a runtime-determined order that is still globally consistent.
The standard technique: assign every resource a unique integer ID. At the moment a function needs two locks, compare the IDs and acquire the lower one first:
type AccountDynamic struct {
mu sync.Mutex
id int // unique resource identifier
balance int
}
func TransferDynamic(from, to *AccountDynamic, amount int) bool {
var first, second *AccountDynamic
if from.id < to.id {
first, second = from, to
} else {
first, second = to, from
}
first.mu.Lock()
defer first.mu.Unlock()
second.mu.Lock()
defer second.mu.Unlock()
if from.balance >= amount {
from.balance -= amount
to.balance += amount
return true
}
return false
}
Now the ordering is decided at runtime — it depends on which two accounts are passed in — but the rule itself (lower ID first) is universal. The previous static example happened to use memory addresses as its implicit ordering key; this version makes the ordering key explicit.
I ran 200 concurrent transfers with alternating directions:
- a1 = id(42), a2 = id(99)
- Rule: always lock [42] then [99]
- Ran 200 concurrent transfers. All succeeded.
- Individual balances varied per execution (they depend on transfer timing) but the total was invariant at 15000.
No deadlock even with 200 goroutines fighting in both directions, because every pair of simultaneous acquisitions agreed on who went first.
When to Use Which
Static ordering is simplest when you have a known set of resources. C++ STL’s std::lock internally uses address-based ordering. The Linux kernel’s lockdep validator detects violations of the global locking order at runtime — if your code disagrees with static analysis, lockdep will panic and print a backtrace.
Dynamic ordering generalizes to any number of resources with an identifier. It’s what databases use for table locks, what distributed systems use for resource IDs. The overhead is one integer comparison per pair acquisition — negligible compared to the cost of deadlock detection and recovery.
The key insight that unifies both approaches: deadlock requires circular-wait, and a total order on lock acquisition makes cycles impossible. Whether the order comes from compile-time knowledge or runtime IDs doesn’t matter — what matters is that every thread agrees on it.
Takeaway
A global total order on lock acquisition — whether fixed at compile time (static) or computed at runtime from resource identifiers (dynamic) — eliminates circular-wait and with it, deadlock. The rule must be uniform across all callers; the moment any path acquires A before B while another acquires B before A, the cycle reappears.