标题

Go 死锁入门:常见场景、排查方法与工程实践


副标题 / 摘要

从 Go 运行时的 deadlock 报错切入,系统讲清死锁的本质、 最常见的触发方式,以及如何在工程中稳定规避。


目标读者

  • 初学者:第一次写 Go 并发代码,对 deadlock 报错感到困惑。
  • 中级开发者:需要建立稳定的并发协作流程,减少线上卡死。
  • 团队负责人:想沉淀一套可执行的并发规范和排查手册。

背景 / 动机

在 Go 里,“死锁(deadlock)”指的是:所有 goroutine 都在等待某个事件发生 (通常是等锁、等 channel、等 WaitGroup),但这个事件永远不会发生。 典型报错是:

fatal error: all goroutines are asleep - deadlock!

一旦触发,程序会卡住或直接退出,线上影响极大。 理解死锁的触发机制与排查路径,是 Go 并发开发的必修课。


核心概念

  • 阻塞(Blocking):goroutine 等待 channel、锁或 WaitGroup,无法继续执行。
  • 无缓冲 channel:发送/接收必须同时发生,否则阻塞。
  • WaitGroup 计数匹配:Add 的次数必须被 Done 抵消。
  • Mutex 不可重入:同一 goroutine 里重复 Lock 会自我阻塞。
  • 锁顺序一致:多把锁必须统一获取顺序,避免交叉等待。

常见出现背景(什么时候容易发生)

  • 生产者/消费者启动顺序错位:发送先发生、接收未就绪,常见于任务队列、worker pool。
  • 扇出/扇入未配对:启动了多个 worker,但聚合端没把结果全部读完。
  • pipeline 未关闭或退出信号缺失:上游结束但下游仍 range 等待。
  • 持锁做阻塞操作:拿着锁去收/发 channel、网络 I/O、或等待另一个锁。
  • 多锁资源交叉持有:两个 goroutine 以不同顺序拿锁,形成循环等待。

为什么会出现(根因归纳)

  1. 等待关系闭环:A 等 B,B 等 C,C 等 A,没有外力打破。
  2. 同步原语用法不成对:channel 收发未配对、WaitGroup 计数未归零。
  3. 协程生命周期不一致:生产者先退出/未 close,消费者无限等。
  4. 锁粒度/顺序不清晰:共享资源越多,锁顺序越容易失控。

A — Algorithm(题目与算法)

Go 运行时判定死锁的核心逻辑是: 当主 goroutine 在等待,且所有其他 goroutine 也都在等待,并且没有任何事件能 推动程序继续执行,runtime 会直接报错并终止。

下面是最常见、最“纯粹”的死锁示例(演示用,实际项目别这么写), 每个错误示例后都给出修复版便于对照。

示例 1:从没人写入的 channel 里接收

package main

func main() {
	ch := make(chan int) // 无缓冲
	<-ch                 // 一直等发送者,没人写 -> 死锁
}

修复 1:保证有发送者(或引入缓冲并确保后续接收)

package main

import "fmt"

func main() {
	ch := make(chan int)
	go func() {
		ch <- 1
	}()
	fmt.Println(<-ch)
}

示例 2:无缓冲 channel 发送但没人接

package main

func main() {
	ch := make(chan int)
	ch <- 1 // 发送要等接收者,当前 goroutine 卡住 -> 死锁
}

修复 2:启动接收方,或让发送发生在有接收者时

package main

import "fmt"

func main() {
	ch := make(chan int)

	go func() {
		fmt.Println(<-ch)
	}()

	ch <- 1
}

示例 3:WaitGroup Add/Done 不匹配

package main

import "sync"

func main() {
	var wg sync.WaitGroup
	wg.Add(1)
	wg.Wait() // 没有任何 goroutine 调用 Done -> 永远等待
}

修复 3:Add/Done 成对出现,Add 在启动 goroutine 前

package main

import "sync"

func main() {
	var wg sync.WaitGroup
	wg.Add(1)

	go func() {
		defer wg.Done()
		// do work
	}()

	wg.Wait()
}

为什么这样能解决?
关键在于所有 goroutine 都按同一顺序获取锁(先 ab), 从而打破“循环等待”这一死锁必要条件。
如果 Goroutine 1 已拿到 a,Goroutine 2 只能在 a 上等待,而不会持有 b 去等待 a,所以不会形成 ABBA 的闭环。
unlockBoth 反向释放是常见习惯(先释放后获取的锁),便于形成清晰的锁层级。


C — Concepts(核心思想)

死锁的本质是等待依赖关系形成闭环:A 等 B,B 等 C,C 又等 A。 在 Go 中,等待条件主要来自三类同步原语:

  1. channel:收发必须对齐。
  2. mutex:锁住后其他 goroutine 无法前进。
  3. WaitGroup:计数没归零就一直等待。

这类问题属于并发控制与资源协调问题,常见于:

  • 阻塞式管道(pipeline)
  • 多协程协作任务(worker pool)
  • 多锁资源共享(缓存、连接池、共享内存结构)

E — Engineering(工程应用)

以下是三个真实工程场景,展示死锁如何发生,以及更安全的写法。

场景 1:任务队列没人消费

背景:主 goroutine 发送任务,但 worker 没启动。
为什么适用:无缓冲 channel 收发不对齐直接死锁。

错误写法:先发送再启动 worker,发送端永久阻塞

package main

func main() {
	tasks := make(chan int)
	tasks <- 1
}

修复:先启动 worker,再发送并关闭

package main

import "fmt"

func main() {
	tasks := make(chan int)

	// 正确:先启动 worker
	go func() {
		for t := range tasks {
			fmt.Println("task", t)
		}
	}()

	tasks <- 1
	close(tasks)
}

场景 2:WaitGroup 计数不归零

背景:主协程等全部任务结束,但 worker 忘了 Done。
为什么适用:计数错就会永久等待。

错误写法:只 Add 不 Done

package main

import "sync"

func main() {
	var wg sync.WaitGroup
	wg.Add(1)

	go func() {
		// 忘记 Done
	}()

	wg.Wait()
}

修复:Add/Done 成对出现

package main

import "sync"

func main() {
	var wg sync.WaitGroup
	wg.Add(1)

	go func() {
		defer wg.Done()
		// do work
	}()

	wg.Wait()
}

场景 3:多锁资源顺序不一致

背景:两个 goroutine 交叉加锁,形成 ABBA。
为什么适用:共享资源多时,锁顺序不一致最容易出问题。

错误写法:锁顺序不一致导致循环等待

package main

import (
	"sync"
	"time"
)

func main() {
	var a, b sync.Mutex
	var wg sync.WaitGroup
	wg.Add(2)

	go func() {
		defer wg.Done()
		a.Lock()
		time.Sleep(20 * time.Millisecond)
		b.Lock()
		b.Unlock()
		a.Unlock()
	}()

	go func() {
		defer wg.Done()
		b.Lock()
		time.Sleep(20 * time.Millisecond)
		a.Lock()
		a.Unlock()
		b.Unlock()
	}()

	wg.Wait()
}

修复:统一锁顺序或封装成统一入口

package main

import "sync"

func main() {
	var a, b sync.Mutex
	var wg sync.WaitGroup
	wg.Add(2)

	lockBoth := func() {
		a.Lock()
		b.Lock()
	}
	unlockBoth := func() {
		b.Unlock()
		a.Unlock()
	}

	go func() {
		defer wg.Done()
		lockBoth()
		// do work
		unlockBoth()
	}()

	go func() {
		defer wg.Done()
		lockBoth()
		// do work
		unlockBoth()
	}()

	wg.Wait()
}

从语法角度的解释
lockBoth := func() { ... } 定义了一个函数值(函数文本)并赋给变量, 调用 lockBoth() 时会按函数体内语句顺序执行:先 a.Lock()b.Lock()
ab 来自外层作用域,被这个函数闭包捕获,所以两个 goroutine 共享同一套 加锁顺序,避免出现“一边先 ab,另一边先 ba”的语法路径。
unlockBoth := func() { b.Unlock(); a.Unlock() } 同样把解锁顺序固定写死, 降低调用端写错顺序的可能性。


R — Reflection(反思与深入)

复杂度分析

死锁不是算法复杂度问题,但排查成本通常与 goroutine 数量成正比。 常见排查路径是堆栈 + 依赖关系图,复杂度 O(n)。

替代方案与常见误区

  • 误区 1:用 sleep 规避问题
    只是暂时躲开死锁,问题会以更隐蔽的形式出现。

  • 误区 2:强行加缓冲
    只会延后阻塞,依赖闭环仍然存在。

  • 误区 3:以为死锁只会在测试出现
    线上复杂并发场景更容易触发,且复现成本更高。

为什么当前方法更工程可行

通过明确的协作约束(收发配对、计数一致、锁顺序固定), 可以在结构上消灭死锁,而不是依赖运行时排查。


S — Summary(总结)

  • 死锁的本质是等待条件永远不满足
  • channel 需要收发对齐,WaitGroup 需要计数归零。
  • Mutex 不可重入,多锁必须统一顺序。
  • 解决死锁靠结构化设计,不靠 sleep 和“试试看”。
  • runtime 报错是最后的保护,但排查成本高。

推荐延伸阅读:


实践指南 / 步骤

1️⃣ 确认 deadlock 报错堆栈

看到 fatal error: all goroutines are asleep - deadlock! 后, 优先定位卡在 <-ch / ch <- / mu.Lock() / wg.Wait() 的位置。

2️⃣ 检查 channel 收发是否配对

ch := make(chan int)

go func() {
	ch <- 1
}()

<-ch

如果使用 range 消费 channel,确保生产者在合适时机 close(ch), 或通过 context / done channel 提供退出信号。

3️⃣ 检查 WaitGroup 计数是否匹配

wg.Add(1)

go func() {
	defer wg.Done()
}()

wg.Wait()

确保 Add 在启动 goroutine 前完成,避免计数被错过。

4️⃣ 统一锁顺序

所有 goroutine 获取锁的顺序必须一致:A -> B -> C

同时避免在持锁时执行阻塞操作(channel 收发、网络 I/O、等待另一个锁)。


可运行示例

package main

import (
	"fmt"
	"sync"
)

func main() {
	ch := make(chan int)
	var wg sync.WaitGroup
	wg.Add(1)

	go func() {
		defer wg.Done()
		ch <- 42
	}()

	fmt.Println(<-ch)
	wg.Wait()
}

解释与原理

  • main 阻塞触发 deadlock:当 main goroutine 卡住且没有其他可执行 goroutine, runtime 判断程序无法再推进。
  • WaitGroup 易出错:Add 在主协程,Done 在子协程,漏写 Done 会永久等待。
  • 锁顺序必须一致:避免 A 等 B、B 等 A 的循环依赖。

常见问题与注意事项

  • Q:给 channel 加缓冲就不会死锁吗?
    A:只能延迟阻塞,无法解决依赖闭环。
  • Q:为什么没有 deadlock 报错,但程序还是卡住?
    A:可能是 goroutine 未全部阻塞,只是业务逻辑卡死。
  • Q:死锁和竞态冲突是一回事吗?
    A:不是,死锁是等待无法推进,竞态是并发写导致结果不确定。

最佳实践与建议

  • 收发成对:无缓冲 channel 必须保证发送者和接收者都存在。
  • 责任明确:谁 Add 谁负责 Done,避免遗漏。
  • 锁顺序一致:多锁场景统一顺序,必要时封装成工具函数。
  • 用 context 管理退出:协程能退出,等待就不会无限增长。

小结 / 结论

死锁不是“偶发 bug”,而是并发设计失误的结构性结果。 建立清晰协作协议(收发对齐、计数一致、锁顺序固定), 可以从源头上避免大多数死锁问题。


参考与延伸阅读


元信息

  • 阅读时长:约 8 分钟
  • 标签:Go、并发、死锁、channel、WaitGroup
  • SEO 关键词:Go 死锁、deadlock、goroutine、channel、WaitGroup、mutex
  • 元描述:面向新手的 Go 死锁入门文章,涵盖常见死锁类型、排查方法与工程规避策略。

行动号召(CTA)

把你遇到的 deadlock 堆栈贴出来试试看,我可以帮你快速定位问题原因。