Go channel 死锁和 close panic 是怎么产生的?底层 hchan 和 happens-before 一次讲清
Go channel 死锁的根因是缓冲区满且没有接收者,发送 goroutine 被挂进 sendq 永久 park,最终 runtime 检测到全部 goroutine 阻塞后 fatal error 杀进程。close panic 的根因是 close 不是幂等操作,两个 goroutine 竞争 close 同一个 channel,输的那个直接 panic。channel 之所以能替代锁,是因为 Go 内存模型的 happens-before 规则保证了数据通过 channel 传递时,写入方之前的内存操作对接收方之后的操作用可见。
死锁是怎么发生的
支付服务流水线:订单事件进 orderCh,处理 goroutine 消费后写进 resultCh,对账模块从 resultCh 消费。两个 channel 都带缓冲。
对账模块被改成配置开关控制,生产环境开关没开。三分钟后:
fatal error: all goroutines are asleep - deadlocked!
goroutine 87 [chan send]:
payment.Pipeline.process(...)
pipeline.go:47 +0x9c
链路:处理 goroutine 从 orderCh 收到订单,处理后往 resultCh 发送;resultCh 缓冲满(没人消费),发送阻塞;所有处理 goroutine 陆续阻塞;orderCh 没人收也满了,生产方阻塞——进程里所有 goroutine 都睡了,runtime 死锁检测器直接 fatal error 杀进程。
关键点:runtime 只能检测「全部 goroutine 都阻塞」的死锁。如果进程里还有别的活跃 goroutine(比如 HTTP 服务还在接请求),死锁不会 fatal error,而是演变成处理 goroutine 永久泄漏、结果队列无限堆积。测试环境更容易看到 fatal(goroutine 少而纯),生产环境反而更容易「带病运行」。
close panic 是怎么发生的
退货流程需要异步通知:「超时 5 秒没收到回调就关闭结果 channel」,同时正常完成也会 close。
// goroutine A:正常完成
close(resultCh)
// goroutine B:超时兜底
select {
case <-time.After(5 * time.Second):
close(resultCh) // panic: close of closed channel
case <-done:
}
close 不是幂等操作,两个 goroutine 竞争 close 同一个 channel,输的那个直接 panic。panic 在异步 goroutine 里没人 recover,进程照挂。
怎么排查死锁和 close panic
死锁排查,进程还活着(部分死锁):
用 pprof 抓栈:curl localhost:6060/debug/pprof/goroutine?debug=2,输出每个 goroutine 首行都带阻塞状态,重点搜三个关键字:
chan send:阻塞在发送chan receive:阻塞在接收semacquire:阻塞在锁
把它们画成图——谁等谁——死锁环一眼可见。
死锁排查,进程已死(全部死锁):
fatal error 的输出本身就带全量 goroutine 栈,别急着重启,先看日志。goroutine 87 [chan send] pipeline.go:47 直接指到发送点,顺着 resultCh 的下游一查就能定位。
close panic 排查:
close of closed channel 的调用栈从 runtime.closechan 展开,栈顶往下一层就是业务代码里 close 的位置。要回答「是谁先 close 的」,需要 go build -race,在测试环境开着竞态检测重放流量,runtime 会在第二次 close 发生时精确报告两个 goroutine 的冲突位置。
竞态检测器查不出「逻辑上不该有第二次 close」这种设计问题,但能确定性抓住物理上的并发冲突。
hchan 底层结构长什么样
channel 的一切行为都可以从 runtime/chan.go 的 hchan 结构体推导出来:
type hchan struct {
qcount uint // 当前队列里的元素个数
dataqsiz uint // 环形缓冲区容量(make 时传入)
buf unsafe.Pointer // 环形缓冲区本体
sendx uint // 下一个写入位置
recvx uint // 下一个读取位置
recvq waitq // 等待接收的 goroutine 队列
sendq waitq // 等待发送的 goroutine 队列
lock mutex
closed uint32
}
buf 是环形队列,sendx/recvx 是读写指针,sendq/recvq 是排队的 goroutine,lock 保护整本账。
chansend 的四条路径
// chansend 的四条路径(伪代码)
lock(&c.lock)
if c.closed != 0 { unlock; panic("send on closed channel") } // 路径④
if sg := c.recvq.pop(); sg != nil { // 路径①:有接收者排队
send(c, sg, ep) // 数据直接从发送方拷进接收方的栈,不过 buf
return
}
if c.qcount < c.dataqsiz { // 路径②:缓冲未满
qp := chanbuf(c, c.sendx); memmove(qp, ep, ...); c.sendx++
return
}
// 路径③:缓冲满且无接收者——当前 goroutine 打包成 sudog 挂进 sendq,gopark 让出
路径①:无缓冲 channel 的传输根本不经过 buf,发送方直接把数据 memcpy 进接收方 goroutine 的栈,两边同时就绪时性能极高。
路径③解释了死锁事故:缓冲满 + 无接收者,goroutine 被挂进 sendq 永久 park。如果永远不会有人来收,这就是泄漏或死锁。
hchan.lock 是一把真互斥锁。channel 不是无锁的,高争用场景下它的表现和 mutex 同级。「用 channel 一定比锁快」是错觉。
channel 的 happens-before 规则
Go 内存模型(2022 修订版)规定:
- 对无缓冲 channel,接收发生在发送完成之前
- 对带缓冲 channel(容量 C),第 k 次接收发生在第 k+C 次发送完成之前
- channel 的 close 发生在「因 close 而收到零值」之前
- 一次发送发生在对应那次接收完成之前
数据通过 channel 交出去的那一刻,「写入方之前的一切内存操作」对「接收方之后的一切操作」可见。channel 不只是传数据,它同时传递了内存可见性。
channel 正确使用的四条规则
规则一:单向类型在函数签名上写清所有权。
func produce(ch chan<- Order) // 只准发
func consume(ch <-chan Order) // 只准收
规则二:由唯一的发送方 close;多个发送方时不 close。
var wg sync.WaitGroup
for i := 0; i < producers; i++ {
wg.Add(1)
go func() { defer wg.Done(); produceLoop(out) }()
}
go func() { wg.Wait(); close(out) }() // 唯一 closer
规则三:用 context 广播退出,而不是用 close 通知。 close 收敛到单一生命周期终点(如 defer close() 配合单一 goroutine 持有)。
规则四:接收侧永远用 v, ok := <-ch 双返回值,ok=false 表示 channel 已关闭且缓冲已排空。
channel 各操作行为表
| 操作 | nil channel | 已关闭 channel | 正常 channel | |------|-------------|----------------|--------------| | 发送 | 永久阻塞 | panic | 阻塞或入队 | | 接收 | 永久阻塞 | 立即返回零值, ok=false | 阻塞或出队 | | close | panic | panic(重复 close) | ok |
nil channel 的「永久阻塞」也是技巧:在 select 里把某个分支的 channel 置为 nil 可以动态禁用该分支。
channel 性能数据
Go 1.22,4 核:
| 场景 | 单次操作耗时 | 说明 | |------|-------------|------| | 无缓冲 channel 收发各一次(双方就绪) | ~100 ns | 走路径①直接拷栈,最快 | | 缓冲 64 的 channel 一发一收 | ~120 ns | 过账本(lock + 环形队列) | | 同场景 + 4 个 goroutine 争用 | ~600 ns | lock 争用开始主导 | | mutex 加锁解锁(无争用) | ~18 ns | 比想象中便宜得多 | | 关闭 channel 广播唤醒 1000 个等待者 | ~4 µs | close 是 O(等待者数) |
三个结论:
- channel 单次开销在百纳秒级,作为业务事件传递介质毫无压力,但别拿它当高频计数器,那个用
atomic.AddInt64,18 纳秒以内 - 争用之下 channel 与 mutex 会趋同,因为它俩底层就是同一把锁;channel 的价值是语义,不是快
- close 广播是 O(N) 唤醒,一万个 goroutine 等同一个 channel 的退出信号也只要几十微秒
常见问题
Q1:channel 的底层数据结构?
从 hchan 讲起:环形缓冲(buf + dataqsiz + sendx/recvx)、等待队列(sendq/recvq)、一把 mutex、closed 标志位。
Q2:为什么无缓冲 channel 传输不经过 buf?
发送方直接把数据 memcpy 进接收方 goroutine 的栈,两边同时就绪时性能极高。
Q3:runtime 能检测到所有死锁吗?
不能。只能检测「全部 goroutine 都阻塞」的死锁,部分死锁会演变成 goroutine 泄漏。
Q4:close 广播退出慢吗?
不慢。close 是 O(等待者数),唤醒 1000 个等待者约 4 µs。
Go channel 死锁和 close panic 的根因都在 hchan 的 sendq/recvq 队列和 close 的非幂等语义上,正确做法是由唯一发送方 close、接收侧用 v, ok := <-ch 判断关闭、用 context 广播退出。
本文是速览版,完整教程看这篇:Go 语言实战(二):channel 的底层账本,原文含完整代码示例与推导过程。
