Bank transfer: two goroutines transfer money between accounts A->B and B->A with per-account mutexes. Why does it deadlock, and how does consistent lock ordering fix it?
Question 520MediumGo 1.22 to 1.25
The naive from.mu.Lock(); to.mu.Lock() deadlocks like this. Goroutine 1 runs A->B and locks A. Goroutine 2 runs B->A and locks B. Now each waits for the lock the other holds. That is a circular wait, and neither can make progress. The fix is to impose a global total order (for example by account ID) and always acquire locks in that order, whatever the transfer direction.
var ErrInsufficient = errors.New("insufficient funds")
type Account struct {
ID int64
mu sync.Mutex
balance int64
}
func Transfer(from, to *Account, amt int64) error {
if from == to {
return errors.New("same account") // Go mutexes are NOT reentrant
}
first, second := from, to
if first.ID > second.ID {
first, second = second, first
}
first.mu.Lock()
defer first.mu.Unlock()
second.mu.Lock()
defer second.mu.Unlock()
if from.balance < amt {
return ErrInsufficient
}
from.balance -= amt
to.balance += amt
return nil
}
Gotchas:
- Self-transfer: locking the same mutex twice deadlocks immediately, because Go has no recursive mutex. Guard against it explicitly.
- Order key: the key must be unique and stable. If you order by pointer address, use
uintptr(unsafe.Pointer(a))only if you accept relying on non-moving heap objects. A stable ID is cleaner. - Two locks are needed: locking
fromalone, thentoalone, sequentially, avoids deadlock but breaks the invariant. Another goroutine can observe money "in flight", so a total-balance audit fails. - Detection: the runtime reports "all goroutines are asleep" only if everything is blocked. In a server it shows up as hung requests. Use a goroutine dump (
SIGQUITor/debug/pprof/goroutine?debug=2) to diagnose it.
What the interviewer is looking for: the circular-wait explanation, the ordering fix, the self-transfer edge case, and alternatives: one global lock (simple but serializes everything), TryLock-and-back-off (livelock risk), or a single goroutine that owns the ledger.
More on Classic Concurrency Coding Problems
- Q518Print "foo", "bar", "baz" in strict order from three goroutines, repeated N times.
- Q519Implement the dining philosophers problem in Go without deadlock or starvation. Compare resource ordering with an arbiter.
- Q521Implement a bounded blocking queue (producer-consumer) using sync.Cond, then using channels. Compare the two.
- Q522Implement a reusable barrier (CyclicBarrier) that N goroutines wait on before moving to the next phase.
- Q523Implement a Future/Promise type in Go with Get(ctx) and error propagation using generics.
- Q524Implement a concurrent prime sieve with a daisy chain of goroutines. How many goroutines does it create, and how do you stop it without leaks?