在Golang的并发编程场景中,协程死锁会导致程序卡住无法继续执行,这类问题大多和锁、channel的使用不当有关,需要从使用规范和场景设计两个层面去规避和解决。

常见的协程死锁触发场景
锁相关的死锁场景
互斥锁是最容易引发死锁的同步工具,常见的问题包括加锁后未释放、嵌套加锁导致互相等待。
比如下面这段嵌套加锁的代码就会触发死锁:
package main
import (
"fmt"
"sync"
)
var mu1, mu2 sync.Mutex
func main() {
// 第一个协程先加mu1再尝试加mu2
go func() {
mu1.Lock()
fmt.Println("goroutine 1 get mu1")
mu2.Lock() // 等待mu2释放
fmt.Println("goroutine 1 get mu2")
mu2.Unlock()
mu1.Unlock()
}()
// 第二个协程先加mu2再尝试加mu1
go func() {
mu2.Lock()
fmt.Println("goroutine 2 get mu2")
mu1.Lock() // 等待mu1释放
fmt.Println("goroutine 2 get mu1")
mu1.Unlock()
mu2.Unlock()
}()
select {} // 阻塞主协程避免程序退出
}
两个协程分别持有对方需要的锁,形成互相等待的状态,就会触发死锁。
channel相关的死锁场景
无缓冲channel的读写操作会互相阻塞,如果只进行发送没有接收者,或者只进行接收没有发送者,就会触发死锁。
比如下面这段无缓冲channel发送的示例:
package main
func main() {
ch := make(chan int)
ch <- 1 // 无缓冲channel发送,没有接收者,直接死锁
}
还有协程内接收无缓冲channel,但是没有发送者的情况,同样会导致死锁。
锁使用的优化方法
控制锁的粒度
锁的粒度越小,持有锁的时间越短,和其他协程冲突的概率就越低。尽量只把需要保护的共享资源操作放在加锁范围内,非共享资源的操作放到锁外面。
优化前的代码:
func updateData(mu *sync.Mutex, data *map[string]int, key string, value int) {
mu.Lock()
// 非共享资源的耗时操作放在锁内,增大锁粒度
time.Sleep(time.Millisecond * 10)
(*data)[key] = value
mu.Unlock()
}
优化后的代码:
func updateData(mu *sync.Mutex, data *map[string]int, key string, value int) {
// 耗时操作放到锁外面
time.Sleep(time.Millisecond * 10)
mu.Lock()
(*data)[key] = value
mu.Unlock()
}
避免嵌套加锁
如果必须加锁,尽量统一所有协程的加锁顺序,比如都按照锁的变量名字典序加锁,避免互相等待的情况。如果可以的话,尽量用读写锁sync.RWMutex代替互斥锁,读多写少的场景下能减少锁冲突。
使用defer确保锁释放
加锁后使用defer释放锁,能避免因为异常或者提前返回导致的锁未释放问题。
func safeLock(mu *sync.Mutex) {
mu.Lock()
defer mu.Unlock() // 无论函数怎么退出,都会执行释放锁操作
// 业务逻辑
}
channel使用的优化方法
合理选择缓冲channel
如果发送和接收的速度不匹配,优先使用带缓冲的channel,缓冲大小根据实际场景设置,避免无缓冲channel的阻塞问题。但是要注意缓冲channel满了之后发送还是会阻塞,需要做好容量规划。
// 创建缓冲大小为10的channel,发送10次以内不会阻塞 ch := make(chan int, 10)
避免无接收者的发送操作
发送channel之前,确保已经有协程在接收,或者使用select配合default分支处理发送阻塞的情况,避免死锁。
func sendData(ch chan int, data int) {
select {
case ch <- data:
fmt.Println("send success")
default:
fmt.Println("channel is full, skip send")
}
}
关闭channel的规范
只由发送方关闭channel,不要由接收方关闭,也不要关闭已经关闭的channel,避免panic。接收方可以通过ok值判断channel是否已经关闭。
func receiveData(ch chan int) {
for {
data, ok := <-ch
if !ok {
fmt.Println("channel closed")
return
}
fmt.Println("receive:", data)
}
}
死锁问题的排查技巧
如果程序已经出现死锁,可以使用runtime包的NumGoroutine函数查看当前协程数量,或者使用pprof工具抓取协程栈信息,找到阻塞的协程位置,定位是锁还是channel导致的阻塞。另外Golang内置的死锁检测会在程序运行时抛出死锁的报错信息,里面会包含阻塞的协程调用栈,直接根据栈信息就能定位问题代码。