Go语言以goroutine和channel简化了并发模型,但开发者仍会频繁遭遇死锁。死锁的本质是两组及以上的执行单元彼此等待对方释放资源或传递信号,导致所有相关协程永久阻塞。运行时检测到全部goroutine休眠且无法恢复时,会主动崩溃并提示死锁。

一、单协程中channel使用不当引发的死锁
最基础的死锁出现在同一个goroutine内对无缓冲channel进行收发。无缓冲channel要求发送和接收必须同时就绪,若仅在一个协程里先接收后发送,接收方会一直阻塞,而发送操作永远没有机会执行。
下面这段代码在主协程中试图从无缓冲channel读取,但没有任何其他协程向该channel写入,程序运行后会立即死锁。
package main
func main() {
ch := make(chan int) // 无缓冲channel
// 主协程接收,但无人发送
val := <-ch
println(val)
}
类似的误区也包括向已关闭且无数据的channel反复接收,或向nil channel收发。nil channel的收发操作会永久阻塞,这也是隐藏死锁的常见来源。解决方式是明确收发双方的生命周期,或改用带缓冲的channel并在发送前确认有接收者。
二、多goroutine互相等待形成的循环死锁
当多个goroutine分别持有不同锁或channel,并等待对方先动作时,便会产生循环依赖。例如协程A持有锁L1并等待从channel C2接收,协程B持有锁L2并等待向channel C1发送,而C1和C2又互相绑定,系统进入僵局。
以下示例展示两个goroutine通过无缓冲channel互发信号却顺序错乱,最终双双阻塞。
package main
func main() {
c1 := make(chan int)
c2 := make(chan int)
go func() {
<-c2 // 等待c2数据
c1 <- 1 // 再向c1发送
}()
go func() {
<-c1 // 等待c1数据
c2 <- 2 // 再向c2发送
}()
select {} // 主协程不退出,便于观察
}
上述代码中,两个子协程都在等待对方先发送,形成典型死锁。实际工程中,这类问题常出现在服务启动顺序错误、资源初始化依赖混乱时。建议绘制协程间通信图,保证不存在环形等待,或使用带超时的select语句避免永久阻塞。
三、锁与channel混用导致的隐性死锁
有些场景同时使用了sync.Mutex和channel,若在持有锁期间阻塞在channel收发,而另一个协程需要同一把锁才能向channel发数据,死锁就会悄然而至。
如下代码,主协程加锁后等待channel,工作协程尝试加锁再发送,结果主协程拿不到发送,工作协程拿不到锁。
package main
import "sync"
func main() {
var mu sync.Mutex
ch := make(chan int)
go func() {
mu.Lock()
ch <- 10 // 需要主协程接收,但主协程正等锁
mu.Unlock()
}()
mu.Lock()
<-ch // 持有锁并等channel,死锁
mu.Unlock()
}
这种混用模式增加了推理难度。原则是:锁的粒度应尽量小,绝不在持锁期间做可能阻塞的channel操作;若必须通信,先释放锁再收发,或改用纯channel编排流程。
四、死锁的排查手段与预防建议
Go自带工具链对死锁较友好。go vet可静态发现部分channel误用;运行时死锁崩溃会打印所有goroutine栈,从中能看到阻塞在哪些行。复杂系统可借助pprof的goroutine profile观察协程数量与状态。
预防上,推荐统一并发模型:要么以channel传递所有权,要么以锁保护共享内存,避免交织。对不确定耗时的收发,一律配合select与time.After设置超时。启动协程时明确其退出路径,防止泄露协程悄悄持有资源。
// 使用超时避免永久阻塞的写法
select {
case v := <-ch:
println(v)
case <-time.After(2 * time.Second):
println("timeout, avoid deadlock")
}
只要在设计阶段梳理清楚协程依赖方向,并给所有跨协程等待加上边界,Go并发死锁完全可控。写并发代码时多问一句:如果对方永远不回应,我的协程会怎样,就能避开绝大多数陷阱。