Go

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 from alone, then to alone, 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 (SIGQUIT or /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

All 16 Classic Concurrency Coding Problems questions