在Golang的并发编程场景中,多个goroutine同时操作共享资源时,很容易因为资源竞争、等待关系闭环等问题产生死锁,导致程序卡住无法继续执行。死锁会让程序失去响应,是并发开发中需要重点规避的问题。

Golang中常见的死锁产生场景
死锁的产生通常和goroutine之间的等待关系有关,以下是几种典型的场景:
- 无缓冲通道的读写不匹配:向无缓冲通道发送数据后,没有其他goroutine接收,发送方会永久阻塞
- 互斥锁重复加锁:同一个goroutine连续两次对同一个
sync.Mutex加锁,会直接阻塞当前goroutine - 等待组使用错误:
sync.WaitGroup的Add数量大于Done的调用次数,会导致Wait永久阻塞 - 多goroutine循环等待:多个goroutine互相持有对方需要的锁,形成等待闭环
Golang内置的死锁检测机制
Golang的运行时系统内置了基础的死锁检测能力,当程序发生死锁时,运行时会自动识别并抛出panic,同时打印出所有goroutine的调用栈信息,帮助开发者定位问题。比如下面这段会产生死锁的代码:
package main
import (
"fmt"
"time"
)
func main() {
ch := make(chan int) // 创建无缓冲通道
ch <- 1 // 向通道发送数据,没有接收方,当前goroutine阻塞
time.Sleep(time.Second)
fmt.Println("程序执行完成")
}
运行这段代码后,Golang运行时会在一段时间后检测到死锁,输出类似以下的错误信息:
fatal error: all goroutines are asleep - deadlock! goroutine 1 [chan send]: main.main() /tmp/main.go:10 +0x45
这个错误信息会明确指出发生死锁的位置,是排查死锁的第一手资料。不过需要注意的是,Golang的运行时死锁检测只能检测到整个程序所有goroutine都阻塞的情况,如果是部分goroutine死锁,运行时可能无法主动发现。
手动排查死锁的常用方法
当程序出现部分goroutine死锁,或者运行时没有抛出死锁panic时,可以通过以下方法手动排查:
1. 使用pprof工具分析goroutine状态
pprof是Golang官方提供的性能分析工具,可以查看当前所有goroutine的状态。如果发现有大量goroutine处于等待通道、等待锁的状态,且长时间没有变化,就可能存在死锁。首先需要在代码中引入pprof相关包并启动服务:
package main
import (
"fmt"
"net/http"
_ "net/http/pprof"
"time"
)
func main() {
// 启动pprof服务
go func() {
http.ListenAndServe("127.0.0.1:6060", nil)
}()
ch := make(chan int)
go func() {
// 这个goroutine会一直等待接收通道数据,没有发送方,形成死锁
<-ch
}()
time.Sleep(time.Minute)
fmt.Println("程序结束")
}
启动程序后,访问127.0.0.1:6060/debug/pprof/goroutine?debug=2就可以看到所有goroutine的详细状态,找到阻塞的goroutine对应的代码位置。
2. 打印关键节点的日志
在加锁、通道读写、WaitGroup等待等关键操作前后打印日志,记录操作的时间和goroutine ID,通过分析日志的时间线和操作顺序,可以梳理出goroutine之间的等待关系,定位死锁原因。
死锁的解决实践
针对不同的死锁场景,有不同的解决方式:
通道相关死锁解决
如果是无缓冲通道导致的死锁,可以改为使用有缓冲通道,或者确保发送和接收操作在不同的goroutine中执行。比如前面的无缓冲通道发送死锁的代码,可以修改为:
package main
import (
"fmt"
"time"
)
func main() {
ch := make(chan int)
// 启动一个goroutine接收通道数据
go func() {
val := <-ch
fmt.Println("接收到数据:", val)
}()
ch <- 1 // 此时有接收方,发送操作不会阻塞
time.Sleep(time.Second)
fmt.Println("程序执行完成")
}
锁相关死锁解决
对于互斥锁重复加锁的问题,需要严格遵循锁的使用规范,确保加锁和解锁成对出现,避免同一个goroutine重复加锁。如果是多goroutine循环等待锁的问题,可以统一所有goroutine的加锁顺序,避免形成等待闭环。例如两个goroutine都需要获取锁A和锁B,都按照先获取A再获取B的顺序加锁,就不会产生死锁。
WaitGroup相关死锁解决
使用sync.WaitGroup时,要确保Add的数量和Done的调用次数完全一致,同时Add操作要在启动goroutine之前执行,避免goroutine启动后才Add导致Wait提前结束或者永久阻塞。正确的使用示例如下:
package main
import (
"fmt"
"sync"
"time"
)
func main() {
var wg sync.WaitGroup
wg.Add(2) // 提前设置需要等待的goroutine数量
go func() {
defer wg.Done()
time.Sleep(time.Second)
fmt.Println("goroutine 1 执行完成")
}()
go func() {
defer wg.Done()
time.Sleep(time.Second)
fmt.Println("goroutine 2 执行完成")
}()
wg.Wait() // 等待两个goroutine都执行完成
fmt.Println("所有任务执行完成")
}
死锁预防的最佳实践
除了出现问题后解决,日常开发中可以通过以下方式预防死锁:
- 尽量减少共享资源的使用,优先使用通道进行goroutine之间的通信,遵循Golang的不要通过共享内存来通信,而要通过通信来共享内存的原则
- 给锁操作设置超时机制,避免无限等待,可以使用
sync.Cond或者结合通道实现带超时的锁获取 - 编写单元测试时,模拟并发场景,验证程序在并发情况下的正确性,提前发现潜在的死锁问题
- 代码审查时重点关注并发相关的逻辑,检查锁的使用、通道的读写、WaitGroup的调用是否符合规范