Golang的并发模型基于goroutine和channel,配合sync包提供的同步原语,能高效实现并发逻辑,但如果使用不当很容易触发死锁。死锁指的是两个或两个以上的goroutine在执行过程中,因争夺资源而造成的一种互相等待的现象,若无外力作用,它们都将无法推进下去。

场景一:无缓冲channel读写阻塞导致死锁
无缓冲channel的读写操作是同步的,发送方会阻塞直到接收方接收数据,接收方也会阻塞直到发送方发送数据。如果只有一个goroutine同时做读写操作,就会直接触发死锁。
package main
func main() {
ch := make(chan int) // 创建无缓冲channel
ch <- 1 // 发送数据,没有接收方,主goroutine阻塞
<-ch // 永远无法执行到这里
}
上面的代码中,主goroutine先向无缓冲channel发送数据,此时没有其他goroutine接收数据,主goroutine会一直阻塞,程序直接抛出死锁错误。正确的做法是将发送操作放到新的goroutine中,让读写操作在不同的goroutine执行。
package main
import "fmt"
func main() {
ch := make(chan int)
go func() {
ch <- 1 // 新goroutine中发送数据
}()
fmt.Println(<-ch) // 主goroutine接收数据
}
场景二:互斥锁重复加锁导致死锁
sync包中的Mutex是互斥锁,同一把锁不能连续加锁两次,否则会触发死锁。如果是同一个goroutine连续调用两次Lock方法,就会直接阻塞。
package main
import "sync"
func main() {
var mu sync.Mutex
mu.Lock()
mu.Lock() // 第二次加锁,当前goroutine已经持有锁,会一直阻塞等待释放
}
这种情况通常出现在函数嵌套调用时,外层函数加了锁,内层函数又对同一个锁加锁,就容易出现重复加锁的问题。规避方法是要明确锁的作用范围,避免同一把锁在同一个goroutine中重复加锁,或者使用sync.RWMutex的读锁特性减少冲突。
场景三:多个goroutine互相等待资源形成死锁循环
当多个goroutine分别持有不同的资源,又同时等待对方释放自己需要的资源时,就会形成死锁循环。这是最典型的死锁场景,和操作系统中的死锁原理一致。
package main
import (
"sync"
"time"
)
func main() {
var muA, muB sync.Mutex
// goroutine1 先持有muA,等待muB
go func() {
muA.Lock()
time.Sleep(time.Second) // 保证goroutine2先持有muB
muB.Lock()
muB.Unlock()
muA.Unlock()
}()
// goroutine2 先持有muB,等待muA
go func() {
muB.Lock()
time.Sleep(time.Second)
muA.Lock()
muA.Unlock()
muB.Unlock()
}()
time.Sleep(3 * time.Second) // 等待两个goroutine执行,触发死锁
}
上面的代码中,两个goroutine分别持有muA和muB,又都在等待对方释放自己需要的锁,形成了互相等待的循环,最终触发死锁。规避这种死锁的方法是统一所有goroutine获取锁的顺序,比如都先获取muA再获取muB,就能避免循环等待的情况。
场景四:channel关闭后继续发送数据导致panic而非死锁
这里需要特别说明,向已经关闭的channel发送数据会直接触发panic,不是死锁,但也是channel使用中的常见错误。而如果从已关闭的channel接收数据,会先接收到channel中剩余的数据,之后会接收到对应类型的零值,不会触发错误。
package main
func main() {
ch := make(chan int, 1)
ch <- 1
close(ch)
ch <- 2 // 向已关闭的channel发送数据,触发panic
}
实际开发中要避免向可能已关闭的channel发送数据,通常可以通过让发送方负责关闭channel,接收方通过ok值判断channel是否关闭的方式来规避问题。
死锁问题的排查与规避建议
当程序出现死锁时,Golang运行时会打印出所有goroutine的堆栈信息,可以通过这些信息定位到阻塞的goroutine和对应的代码位置。日常开发中规避死锁可以遵循几个原则:无缓冲channel的读写尽量放在不同的goroutine中;明确锁的作用范围,避免重复加锁;统一多锁获取的顺序;不要随意关闭可能被其他goroutine继续发送数据的channel。
另外,对于复杂的并发逻辑,可以使用go vet工具检测代码中的潜在问题,也可以在测试阶段使用-race参数检测竞态条件,提前发现并发相关的问题。