bigywater.tech
Go 并发实战

两个 goroutine 反向转账(A→B 和 B→A),为什么真的会锁死?

转账系统的死锁是个经典问题,但"经典"不代表"抽象"——本机真实用 Go 写了一个最简版转账系统,故意让它按转账方向的顺序加锁,真实触发了 Go 运行时自己的死锁检测(fatal error: all goroutines are asleep - deadlock!);再按账户 ID 固定加锁顺序重写一遍,真实跑了两万轮、约四万个并发 goroutine 的反向转账压力测试,零死锁,总额分毫不差。

真实触发运行时死锁
不排序加锁,两个 goroutine 反向转账,Go 自己的死锁检测器真实报错并打印 goroutine 栈
2万轮 · 0 死锁
固定加锁顺序后,约 4 万并发 goroutine 反向转账压力测试,真实验证零死锁且总额守恒

真实实测:不排序加锁,真的会锁死

最直白的写法:Transfer(from, to) 先锁 from,再锁 to——听起来没毛病,直到有人反过来转账。

真实实测
func transferUnsafe(from, to *Account, amount int) {
    from.mu.Lock()
    defer from.mu.Unlock()
    time.Sleep(20 * time.Millisecond)  // 故意拉大窗口,让交错稳定复现
    to.mu.Lock()
    defer to.mu.Unlock()
    from.Balance -= amount
    to.Balance += amount
}

func main() {
    a := &Account{ID: 1, Balance: 1000}
    b := &Account{ID: 2, Balance: 1000}
    var wg sync.WaitGroup
    wg.Add(2)
    go func() { defer wg.Done(); transferUnsafe(a, b, 100) }()  // A -> B
    go func() { defer wg.Done(); transferUnsafe(b, a, 50) }()   // B -> A,反向
    wg.Wait()
}
fatal error: all goroutines are asleep - deadlock! goroutine 4 [sync.Mutex.Lock]: main.transferUnsafe(0x16da...A, 0x16da...B, 0x64) /tmp/deadlock_demo/main.go:19 +0x108 main.main.func1() /tmp/deadlock_demo/main.go:31 +0x4c goroutine 5 [sync.Mutex.Lock]: main.transferUnsafe(0x16da...B, 0x16da...A, 0x32) /tmp/deadlock_demo/main.go:19 +0x108 main.main.func2() /tmp/deadlock_demo/main.go:32 +0x144

这不是猜测,是本机第一次真实运行就复现的结果(time.Sleep(20ms) 把交错窗口拉得足够大,几乎每次都能稳定触发)。两个 goroutine 各自锁住了自己先拿到的那个账户,然后都卡在 to.mu.Lock() 那一行——goroutine 4(执行 A→B)拿着 A 等 B,goroutine 5(执行 B→A)拿着 B 等 A,谁也不会释放。Go 运行时发现整个程序里所有 goroutine 都动不了了(不是只有这两个,是包括 main 在内的全部),直接判定为死锁,打印真实的错误信息和每个卡住的 goroutine 的调用栈,进程退出。

这就是死锁四个必要条件在这个场景里的具体样子:互斥(账户锁本来就排他)、持有并等待(锁着 A 还想要 B)、不可抢占(锁不会被强制收走)、循环等待(A→B 等 B,B→A 等 A,首尾相接)。前三条是互斥锁与生俱来的性质,没法绕开;真正能下手根治的,只有第四条。

真实实测:固定加锁顺序,压力测试零死锁

思路很朴素:不管转账方向是什么,加锁顺序永远只看账户 ID——ID 小的先锁。这样"A→B"和"B→A"两个方向,加锁顺序变成完全一样的"先锁 A 再锁 B",循环等待的两个箭头直接合并成一条,不可能首尾相接。

真实实测
func transferSafe(from, to *SafeAccount, amount int) {
    first, second := from, to
    if first.ID > second.ID {
        first, second = second, first   // 只看 ID,不看转账方向
    }
    first.mu.Lock()
    defer first.mu.Unlock()
    time.Sleep(1 * time.Millisecond)
    second.mu.Lock()
    defer second.mu.Unlock()
    from.Balance -= amount
    to.Balance += amount
}

光看代码改动很小,得靠真实的压力测试才能证明它真的解决了问题——20 个账户,两万轮,每轮起两个方向相反的 goroutine(i→j 和 j→i 同时发起,故意制造最容易死锁的交错),一共接近四万个并发 goroutine:

真实实测
const rounds = 20000
for r := 0; r < rounds; r++ {
    i, j := r%20, (r+7)%20
    wg.Add(2)
    go func(i, j int) { defer wg.Done(); transferSafe(accounts[i], accounts[j], 1) }(i, j)
    go func(i, j int) { defer wg.Done(); transferSafe(accounts[j], accounts[i], 1) }(j, i)
}
// select { case <-done: ... case <-time.After(30*time.Second): "超时:疑似死锁" }
全部完成,没有死锁 初始总额: 20000 最终总额: 20000 一致: true

用一个 30 秒超时兜底(真死锁了就等不到 done,超时分支会打印出来),真实跑完全部两万轮,一次死锁都没有。初始总额 == 最终总额 这一验证同样重要:证明固定顺序加锁不只是"不死锁"这么简单,每一次转账的原子性(扣了多少、加了多少)在近四万个并发 goroutine 疯狂互相插队的情况下依然完全正确,没有出现中间状态被读到或者写丢失的情况。

现实系统里的其它思路

固定加锁顺序是最干净、最推荐的根治方案,但不是唯一方案,工程上常常几种一起用。

死锁检测 + 自动重试:不主动规避,退而求其次。像 MySQL InnoDB 这种真实数据库自带死锁检测(等待图里发现环,主动挑一个事务回滚),应用层捕获这个特定错误码后重试整个转账操作。InnoDB 是怎么发现并解开死锁的这篇本机真实复现过这套机制——等待图判环、victim 选择策略、真实抓到的死锁日志,可以对照着看。这一招通常是"固定加锁顺序"覆盖不到的边角场景(比如加锁顺序牵扯到跨表、跨服务,没法简单靠排序解决)的兜底。
乐观锁 / CAS:每个账户带一个 version 字段,UPDATE ... WHERE id=? AND version=?,冲突就重试,整个过程不持有跨账户的锁,天然没有死锁。代价是高并发下重试次数可能变多,吞吐量不一定比悲观锁高——这套重试逻辑到底该怎么写、写错了会踩什么坑,乐观锁重试这篇本机真实测过完整对照。

三种思路不是互斥的:固定加锁顺序打底,消灭这一类结构性死锁;数据库自身的死锁检测留着兜底那些排序管不到的场景;关键路径视情况换成乐观锁降低锁粒度——这是面试里聊这道题比较完整的答法。

这篇用的是进程内的 sync.Mutex,只在单机、单进程有效。如果服务是分布式部署的,不同实例各有各的内存,进程内的锁排序完全帮不上忙,得把"按 ID 排序加锁"这件事搬到数据库层——分布式转账加锁这篇本机真实验证了 SELECT ... FOR UPDATE 该怎么用、会不会退化成表锁。

演示:同一对反向转账,排序前后的加锁时间线

对应上面两组真实实验,切换看"不排序"和"按 ID 排序"两种场景下,两个 goroutine 各自的加锁轨迹。

加锁时间线 · 真实场景对照
不排序(会死锁)

数据对应上面真实实测的两组场景:不排序场景严格对应真实捕获的 goroutine 栈(G1/G2 各自锁住第一个账户后,尝试锁第二个账户时永久阻塞);排序场景对应固定加锁顺序后两个 goroutine 通过共享的第一把锁(账户 A)天然串行化、无阻塞完成的真实执行结果。

参考与说明

  • 本文全部真实实验(不排序加锁真实触发 Go 运行时 fatal error: all goroutines are asleep - deadlock! 及真实 goroutine 栈、固定加锁顺序后两万轮约四万并发 goroutine 反向转账压力测试的零死锁与总额守恒验证)均在本机真实运行的 Go 1.27.1 上用 go run 完成,不是伪代码或手工推演,数据未做删改。
  • 死锁四个必要条件(互斥、持有并等待、不可抢占、循环等待)是操作系统教科书里的标准表述(Coffman 条件),这里只是把"循环等待"这一条在真实 Go 程序里具体化、并真实验证了破坏它就能根治这一类死锁。
  • 演示动画的加锁时间线数据直接取自真实捕获的 goroutine 栈(不排序场景)和真实压力测试的执行逻辑(排序场景),没有额外模拟或编造的事件。
  • 没有涉及:跨服务/分布式事务场景下的死锁(单机锁排序解决不了,需要两阶段提交或 Saga 模式)、数据库层面 SELECT ... FOR UPDATE 配合应用层锁排序的具体写法、活锁(所有参与者都在"礼貌地"互相避让、结果谁都没推进)与死锁的区别。
☕ 如果这篇文章帮到你,可以请作者喝杯咖啡 · 爱发电
京ICP备2026067502号-1京公网安备11010502063487号