你有没有遇到过这样的情况:Go 程序运行到一半突然卡住,日志不输出、接口没响应,查看进程发现 CPU 占用几乎为 0,重启后又正常?十有八九,这是遇到了「死锁」。
死锁就像两个人在狭窄的走廊里碰面,都想让对方先让开,结果谁也动不了 —— 程序中的 goroutine 也会因为「互相等待资源」陷入这种僵局,最终整个流程卡死。
今天这篇文章,我们用大白话 + 代码例子,彻底搞懂 Go 中死锁的「4 个必要条件」,拆解 5 个高频死锁场景,再给出 6 个实用避坑方法,让你不仅能看懂死锁,还能从根源避免它。
一、先搞懂:死锁到底是什么?
在讲技术细节前,先举个生活中的例子:
假设你和朋友去餐厅吃饭,需要「筷子 + 勺子」才能用餐。你手里拿着唯一的筷子,等着朋友手里的勺子;朋友手里拿着唯一的勺子,等着你的筷子 —— 你们俩都拿着一个资源,又等着对方的资源,永远等不到结果,这就是死锁。
对应到 Go 程序中,「你和朋友」就是 goroutine,「筷子和勺子」就是程序中的资源(比如锁、通道、文件句柄)。当多个 goroutine 互相持有对方需要的资源,且都不释放自己的资源时,就会陷入永远等待的僵局,这就是死锁。
死锁的核心特征是:程序卡住不推进,无错误日志,资源(CPU、内存)占用低—— 因为 goroutine 都在等待,没做实际工作。
二、死锁的 4 个必要条件:少一个都不会发生
死锁不是随便就能发生的,它必须同时满足 4 个条件(这是计算机科学中的经典理论,对 Go 同样适用)。只要破坏其中任意一个条件,死锁就不会发生。
我们逐个拆解这 4 个条件,每个条件都配 Go 代码例子,让你一看就懂。
条件 1:互斥条件 —— 资源只能「独占使用」
定义:某个资源只能被一个 goroutine 占用,其他 goroutine 要使用,必须等当前占用者释放。
就像厕所隔间,一次只能一个人用;Go 中的 sync.Mutex
(互斥锁)就是典型的「互斥资源」—— 一个 goroutine 加锁后,其他 goroutine 必须等它解锁才能加锁。
代码例子:
package main
import (
"sync"
"time"
)
func main() {
var mu sync.Mutex // 互斥锁(互斥资源)
// goroutine1 占用锁
go func() {
mu.Lock() // 加锁:占用资源
defer mu.Unlock() // 退出时解锁:释放资源
time.Sleep(3 * time.Second) // 模拟占用3秒
println("goroutine1:释放锁")
}()
// 主goroutine 等待后尝试获取锁
time.Sleep(1 * time.Second)
mu.Lock() // 此时goroutine1还没解锁,主goroutine会阻塞
defer mu.Unlock()
println("主goroutine:获取到锁")
}
说明:这个例子中,互斥锁满足「互斥条件」,但不会死锁 —— 因为主 goroutine 只是「等待资源」,没有持有其他资源,等 goroutine1 释放锁后,主 goroutine 就能获取到。但如果加上其他条件,就可能触发死锁。
条件 2:持有并等待条件 —— 拿着资源等其他资源
定义:一个 goroutine 已经持有了至少一个资源,还在等待获取其他 goroutine 持有的资源,且不释放自己已持有的资源。
还是用餐厅的例子:你拿着筷子(已持有资源),还在等朋友的勺子(等待其他资源),且不把筷子给朋友 —— 这就是「持有并等待」。
代码例子(结合条件 1,仍未死锁):
package main
import (
"sync"
"time"
)
func main() {
var mu1, mu2 sync.Mutex // 两个互斥锁(两个资源)
// goroutine1:持有mu1,等待mu2
go func() {
mu1.Lock() // 已持有mu1
defer mu1.Unlock()
time.Sleep(1 * time.Second) // 模拟占用mu1
mu2.Lock() // 等待mu2(此时mu2未被占用)
defer mu2.Unlock()
println("goroutine1:获取到两个锁")
}()
// 主goroutine:直接获取mu2(不持有其他资源)
time.Sleep(2 * time.Second) // 等goroutine1释放mu1
mu2.Lock()
defer mu2.Unlock()
println("主goroutine:获取到mu2")
}
说明:goroutine1 持有 mu1 并等待 mu2,但主 goroutine 没有持有任何资源,只是获取 mu2—— 所以不会死锁。但如果主 goroutine 也「持有资源并等待」,就会触发下一个条件。
条件 3:不可剥夺条件 —— 资源不能「强行抢走」
定义:一个 goroutine 持有的资源,不能被其他 goroutine 强行剥夺,只能由持有它的 goroutine 主动释放。
就像你拿着筷子,别人不能直接从你手里抢走,只能等你主动放下;Go 中的互斥锁也满足这个条件 —— 一个 goroutine 加锁后,其他 goroutine 不能强行解锁,只能等它自己调用 Unlock()
。
为什么这是死锁条件:如果资源可以被强行剥夺,比如系统能把你手里的筷子抢过来给朋友,朋友就能先用勺子 + 筷子吃饭,吃完再把筷子还你,就不会陷入僵局。但 Go 中没有这种「强行剥夺」机制,所以这个条件默认满足。
条件 4:循环等待条件 —— 互相等待对方的资源
定义:多个 goroutine 形成一个循环,每个 goroutine 都在等待下一个 goroutine 持有的资源。
比如:goroutineA 持有资源 1,等资源 2;goroutineB 持有资源 2,等资源 1—— 两者形成循环等待,永远等不到结果。
代码例子(4 个条件全满足,触发死锁):
package main
import (
"sync"
"time"
)
func main() {
var mu1, mu2 sync.Mutex // 两个互斥资源
// goroutine1:持有mu1,等mu2(条件2)
go func() {
mu1.Lock()
defer mu1.Unlock()
time.Sleep(1 * time.Second) // 确保先持有mu1
// 等待mu2:此时mu2已被goroutine2持有
mu2.Lock()
defer mu2.Unlock()
println("goroutine1:获取到两个锁(不会执行)")
}()
// goroutine2:持有mu2,等mu1(条件2)
go func() {
mu2.Lock()
defer mu2.Unlock()
time.Sleep(1 * time.Second) // 确保先持有mu2
// 等待mu1:此时mu1已被goroutine1持有
mu1.Lock()
defer mu1.Unlock()
println("goroutine2:获取到两个锁(不会执行)")
}()
// 主goroutine等待,防止程序退出
select {} // 主goroutine阻塞,程序卡住(死锁)
}
运行结果:程序会卡住,日志什么都不输出 —— 因为 4 个条件全满足:
-
互斥:mu1 和 mu2 都是互斥锁;
-
持有并等待:两个 goroutine 都拿着一个锁等另一个;
-
不可剥夺:锁不能被强行抢走;
-
循环等待:goroutine1 等 mu2(goroutine2 持有),goroutine2 等 mu1(goroutine1 持有)。
这就是一个典型的 Go 死锁案例 —— 也是实际开发中最容易遇到的死锁类型。
三、Go 中 5 个高频死锁场景:看完少踩坑
知道了死锁的 4 个条件后,我们来看看 Go 开发中最常遇到的 5 个死锁场景,每个场景都告诉你「为什么会发生」和「怎么改」。
场景 1:无缓冲通道「只发不收」或「只收不发」
原理:Go 中的无缓冲通道(chan T
)有个特性:发送方会阻塞,直到有接收方接收;接收方会阻塞,直到有发送方发送。如果只有发送没有接收(或反之),goroutine 会永远阻塞,导致死锁。
死锁代码(只发不收):
package main
func main() {
ch := make(chan int) // 无缓冲通道
// 主goroutine发送数据,但没有接收方
ch <- 10 // 发送方阻塞,程序卡住(死锁)
println("数据发送成功(不会执行)")
}
为什么死锁:无缓冲通道需要「发送方和接收方同时就绪」才能完成通信。这个例子中只有发送方,没有接收方,主 goroutine 会永远阻塞在 ch <- 10
,满足「循环等待」(主 goroutine 等接收方,但没有接收方)。
修复方法:添加接收方(启动一个 goroutine 接收):
package main
func main() {
ch := make(chan int)
// 启动接收方goroutine
go func() {
data := <-ch // 接收数据
println("接收方:收到数据", data)
}()
ch <- 10 // 发送方:此时有接收方,不会阻塞
println("发送方:数据发送成功")
}
场景 2:goroutine 互相等待通道数据
原理:两个 goroutine 互相给对方的通道发数据,且都先发送再接收 —— 导致双方都阻塞在发送步骤,形成循环等待。
死锁代码:
package main
func main() {
ch1 := make(chan int)
ch2 := make(chan int)
// goroutine1:先给ch2发数据,再从ch1收数据
go func() {
ch2 <- 20 // 步骤1:发ch2,等待goroutine2接收
data := <-ch1 // 步骤2:收ch1(永远执行不到)
println("goroutine1:收到", data)
}()
// goroutine2:先给ch1发数据,再从ch2收数据
go func() {
ch1 <- 10 // 步骤1:发ch1,等待goroutine1接收
data := <-ch2 // 步骤2:收ch2(永远执行不到)
println("goroutine2:收到", data)
}()
select {} // 主goroutine阻塞
}
为什么死锁:goroutine1 阻塞在 ch2 <- 20
(等 goroutine2 收),goroutine2 阻塞在 ch1 <- 10
(等 goroutine1 收)—— 双方都持有「发送动作」,等待对方的「接收动作」,形成循环等待。
修复方法:调整顺序,先接收再发送(或用带缓冲通道):
package main
func main() {
ch1 := make(chan int)
ch2 := make(chan int)
// goroutine1:先收ch1,再发ch2
go func() {
data := <-ch1 // 先接收(等goroutine2发)
println("goroutine1:收到", data)
ch2 <- 20 // 再发送(此时goroutine2已准备接收)
}()
// goroutine2:先发ch1,再收ch2
go func() {
ch1 <- 10 // 先发送(goroutine1已准备接收)
data := <-ch2 // 再接收(等goroutine1发)
println("goroutine2:收到", data)
}()
time.Sleep(1 * time.Second) // 等goroutine执行完
}
场景 3:忘记释放锁(Mutex/RWMutex)
原理:sync.Mutex
加锁后,如果因为「提前 return」「panic」等原因没解锁,其他 goroutine 会永远等待这个锁,导致死锁。
死锁代码(提前 return 没解锁):
package main
import (
"sync"
"time"
)
func main() {
var mu sync.Mutex
var count int
// goroutine1:加锁后提前return,没解锁
go func() {
mu.Lock()
if count > 0 { // 假设count初始为0,不满足条件,但如果条件满足...
// 错误:这里return会导致锁没释放!
return
}
count++
mu.Unlock() // 只有count<=0时才会解锁
println("goroutine1:执行完成")
}()
// goroutine2:等待锁,但永远等不到(如果goroutine1提前return)
time.Sleep(1 * time.Second)
mu.Lock() // 阻塞,程序卡住
defer mu.Unlock()
count++
println("goroutine2:执行完成(不会执行)")
}
为什么死锁:如果 goroutine1 因为条件满足提前 return,会跳过 mu.Unlock()
,导致锁永远被持有。goroutine2 尝试加锁时会永远阻塞,满足「持有并等待」和「循环等待」(goroutine2 等锁,锁被 goroutine1 持有且不释放)。
修复方法:用 defer
确保解锁(defer
会在函数退出前执行,无论是否 return):
go func() {
mu.Lock()
defer mu.Unlock() // 关键:提前加defer,确保解锁
if count > 0 {
return // 即使return,defer也会解锁
}
count++
println("goroutine1:执行完成")
}()
场景 4:sync.WaitGroup 计数不匹配
原理:sync.WaitGroup
用于等待多个 goroutine 完成 ——Add(n)
设为 n 个 goroutine,每个 goroutine 执行完调用 Done()
(相当于计数 - 1),主 goroutine 调用 Wait()
等待计数到 0。如果「计数多了」(Add
多了,Done
少了),Wait()
会永远阻塞,导致死锁。
死锁代码(计数多了):
package main
import (
"sync"
"time"
)
func main() {
var wg sync.WaitGroup
// 错误:Add(3),但只启动2个goroutine
wg.Add(3)
// goroutine1:执行完调用Done()
go func() {
defer wg.Done()
time.Sleep(1 * time.Second)
println("goroutine1:完成")
}()
// goroutine2:执行完调用Done()
go func() {
defer wg.Done()
time.Sleep(1 * time.Second)
println("goroutine2:完成")
}()
// 主goroutine等待计数到0,但实际计数会停在1(3-2=1)
wg.Wait() // 永远阻塞,程序卡住(死锁)
println("所有goroutine完成(不会执行)")
}
为什么死锁:wg.Add(3)
表示要等 3 个 goroutine 完成,但只启动了 2 个,Done()
只调用 2 次,wg.Wait()
会永远等待第 3 个 Done()
,导致主 goroutine 阻塞。
修复方法:确保 Add(n)
的 n 与 goroutine 数量一致:
// 正确:Add(2),与goroutine数量匹配
wg.Add(2)
或在每个 goroutine 启动前调用 Add(1)
(更安全,避免计数错误):
// 更安全的写法:每个goroutine启动前Add(1)
go func() {
wg.Add(1)
defer wg.Done()
// ...
}()
场景 5:循环等待多个锁(最经典的死锁)
原理:多个 goroutine 按不同顺序申请多个锁,形成循环等待 —— 这就是我们在「死锁 4 个条件」中举的例子,也是实际开发中最容易犯的错(比如处理订单时,同时申请「用户锁」和「订单锁」)。
死锁代码(不同顺序申请锁):
package main
import (
"sync"
"time"
)
func main() {
var userLock sync.Mutex // 用户锁
var orderLock sync.Mutex // 订单锁
// goroutine1:先申请用户锁,再申请订单锁
go func() {
userLock.Lock()
defer userLock.Unlock()
time.Sleep(1 * time.Second) // 确保先拿到用户锁
orderLock.Lock()
defer orderLock.Unlock()
println("goroutine1:处理用户+订单(不会执行)")
}()
// goroutine2:先申请订单锁,再申请用户锁(顺序相反)
go func() {
orderLock.Lock()
defer orderLock.Unlock()
time.Sleep(1 * time.Second) // 确保先拿到订单锁
userLock.Lock()
defer userLock.Unlock()
println("goroutine2:处理订单+用户(不会执行)")
}()
select {} // 主goroutine阻塞
}
为什么死锁:goroutine1 持有用户锁等订单锁,goroutine2 持有订单锁等用户锁 —— 按不同顺序申请锁,形成循环等待,4 个死锁条件全满足。
修复方法:所有 goroutine 按「固定顺序」申请锁(比如先申请用户锁,再申请订单锁):
// goroutine2:调整顺序,先申请用户锁,再申请订单锁(与goroutine1一致)
go func() {
userLock.Lock() // 先申请用户锁
defer userLock.Unlock()
time.Sleep(1 * time.Second)
orderLock.Lock() // 再申请订单锁
defer orderLock.Unlock()
println("goroutine2:处理订单+用户(正常执行)")
}()
四、6 个实用方法:从根源避免死锁
知道了死锁的条件和场景后,我们来总结 6 个可落地的避坑方法 —— 本质就是「破坏死锁的 4 个必要条件」,让死锁无法发生。
方法 1:按固定顺序申请资源(破坏「循环等待」)
这是避免「多锁死锁」最有效的方法 —— 所有 goroutine 申请多个资源时,都按同一个固定顺序(比如按资源的 ID 从小到大、按锁的变量名排序)。
比如处理「用户 + 订单」时,无论哪个 goroutine,都先申请「用户锁」,再申请「订单锁」—— 这样就不会形成循环等待。
代码例子(固定顺序申请锁):
// 规则:所有goroutine都先申请userLock,再申请orderLock
func processUserAndOrder(userLock, orderLock *sync.Mutex) {
userLock.Lock() // 固定顺序1:用户锁
defer userLock.Unlock()
time.Sleep(1 * time.Second)
orderLock.Lock() // 固定顺序2:订单锁
defer orderLock.Unlock()
println("处理用户和订单")
}
func main() {
var userLock, orderLock sync.Mutex
go processUserAndOrder(&userLock, &orderLock)
go processUserAndOrder(&userLock, &orderLock) // 同一顺序,不会死锁
time.Sleep(3 * time.Second)
}
方法 2:用带缓冲通道(破坏「持有并等待」)
无缓冲通道需要「发送方和接收方同时就绪」,容易因为「只发不收」或「互相等待」导致死锁。而带缓冲通道(chan T
,make(chan T, n)
)有一个「缓冲区」,发送方在缓冲区未满时不会阻塞,接收方在缓冲区非空时不会阻塞 —— 可以避免很多通道死锁。
代码例子(用带缓冲通道修复「互相等待」):
package main
func main() {
// 带缓冲通道(缓冲区大小1)
ch1 := make(chan int, 1)
ch2 := make(chan int, 1)
// goroutine1:发ch2,再收ch1
go func() {
ch2 <- 20 // 带缓冲,即使goroutine2没接收,也不会阻塞(缓冲区未满)
data := <-ch1
println("goroutine1:收到", data)
}()
// goroutine2:发ch1,再收ch2
go func() {
ch1 <- 10 // 带缓冲,不会阻塞
data := <-ch2
println("goroutine2:收到", data)
}()
time.Sleep(1 * time.Second)
}
说明:带缓冲通道的缓冲区相当于「临时存储区」,打破了「发送方必须等待接收方」的限制,从而破坏「持有并等待」条件。
方法 3:用 context 设置超时(破坏「持有并等待」)
如果 goroutine 持有资源并等待其他资源时,能在「超时后主动释放资源」,就不会永远等待 ——Go 中的 context.WithTimeout
可以实现这个功能:超时后,goroutine 主动解锁或放弃等待,释放已持有的资源。
代码例子(超时释放锁):
package main
import (
"context"
"sync"
"time"
)
func main() {
var mu1, mu2 sync.Mutex
// goroutine1:持有mu1,等待mu2,超时后释放mu1
go func() {
mu1.Lock()
defer mu1.Unlock()
// 创建1秒超时的context
ctx, cancel := context.WithTimeout(context.Background(), 1*time.Second)
defer cancel()
// 尝试获取mu2,超时则放弃
select {
case <-ctx.Done():
// 超时:释放mu1(defer会执行),不再等待mu2
println("goroutine1:等待mu2超时,释放mu1")
return
default:
// 尝试获取mu2(如果获取不到,会阻塞,直到超时)
mu2.Lock()
defer mu2.Unlock()
println("goroutine1:获取到两个锁")
}
}()
// goroutine2:持有mu2,等待mu1,超时后释放mu2
go func() {
mu2.Lock()
defer mu2.Unlock()
ctx, cancel := context.WithTimeout(context.Background(), 1*time.Second)
defer cancel()
select {
case <-ctx.Done():
println("goroutine2:等待mu1超时,释放mu2")
return
default:
mu1.Lock()
defer mu1.Unlock()
println("goroutine2:获取到两个锁")
}
}()
time.Sleep(2 * time.Second)
}
说明:超时后,goroutine 会主动放弃等待,释放已持有的锁 —— 打破了「持有并等待」条件,避免死锁。
方法 4:避免 goroutine 泄漏(防止「资源永远被持有」)
goroutine 泄漏(启动的 goroutine 永远不退出)会导致它持有的资源(锁、通道)永远不释放,其他 goroutine 会永远等待这些资源,最终导致死锁。
比如:
-
启动的 goroutine 里有无限循环,且没有退出机制;
-
通道接收方永远等不到数据(发送方退出)。
避免方法:
-
给每个 goroutine 加退出机制(用
context
或退出通道); -
用
sync.WaitGroup
确保所有 goroutine 都能退出; -
避免在 goroutine 里写无限循环,除非有明确的退出条件。
代码例子(用 context 避免 goroutine 泄漏):
package main
import (
"context"
"time"
)
func main() {
// 创建可取消的context
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
// 启动goroutine,有退出机制
go func() {
for {
select {
case <-ctx.Done():
// 收到退出信号,主动退出(避免泄漏)
println("goroutine:收到退出信号,退出")
return
default:
// 模拟业务逻辑
time.Sleep(500 * time.Millisecond)
println("goroutine:处理业务")
}
}
}()
// 主goroutine运行2秒后退出
time.Sleep(2 * time.Second)
println("主goroutine:退出")
}
方法 5:用工具提前检测死锁风险
Go 提供了多个工具,可以在开发阶段检测死锁风险,不用等到线上出问题才排查:
(1)race 检测器(检测数据竞争和死锁)
用 go run -race
运行程序,会检测数据竞争(数据竞争可能导致死锁)和部分死锁场景:
go run -race main.go
(2)go vet(静态分析工具)
go vet
会检查代码中的常见错误,比如「无缓冲通道只发不收」「sync.WaitGroup 计数不匹配」等:
go vet main.go
(3)pprof(排查已发生的死锁)
如果程序已经死锁,可以用 pprof
查看 goroutine 的状态,找到阻塞的 goroutine:
# 1. 先找到程序的PID
ps aux | grep 程序名
# 2. 用pprof查看goroutine状态
go tool pprof -goroutine 程序名 PID
运行后输入 web
,会生成 goroutine 状态图,清晰看到哪些 goroutine 阻塞在哪个资源上。
方法 6:尽量减少资源持有时间
goroutine 持有资源的时间越短,其他 goroutine 等待的时间就越短,死锁的概率也越低。
实践建议:
-
锁的粒度要小:只在需要保护数据的代码段加锁,不要整个函数加锁;
-
尽快释放资源:获取锁后,尽快执行完临界区代码,不要在锁内做耗时操作(比如网络请求、睡眠);
-
避免在锁内调用外部函数:外部函数可能有未知逻辑(比如又加了其他锁),增加死锁风险。
代码例子(减小锁粒度):
// 不好的写法:整个函数加锁,持有时间长
func badExample(mu *sync.Mutex, data []int) {
mu.Lock()
defer mu.Unlock()
// 耗时操作(不应该在锁内)
time.Sleep(1 * time.Second)
// 实际需要保护的代码(只有这一行)
data[0] = 100
}
// 好的写法:只在需要保护的代码段加锁
func goodExample(mu *sync.Mutex, data []int) {
// 耗时操作(在锁外)
time.Sleep(1 * time.Second)
// 只在修改数据时加锁,持有时间短
mu.Lock()
data[0] = 100
mu.Unlock()
}
五、总结:死锁避坑的核心原则
死锁虽然看起来可怕,但只要记住「4 个必要条件」和「6 个避坑方法」,就能从根源避免:
-
理解死锁条件:死锁必须同时满足「互斥、持有并等待、不可剥夺、循环等待」,破坏任意一个就不会发生;
-
通道使用注意:无缓冲通道要确保「有发有收」,复杂场景用带缓冲通道;
-
锁的使用规范:按固定顺序申请锁,用
defer
确保解锁,减小锁粒度; -
工具提前检测:开发阶段用
race
、go vet
检测风险,线上用pprof
排查问题; -
goroutine 有退出机制:用
context
或退出通道,避免 goroutine 泄漏。
死锁是 Go 并发编程中常见的问题,但通过理解其产生条件和采取适当的预防措施,我们可以有效地避免它。
记住,良好的程序设计和仔细的资源管理是预防死锁的最有效方法。在编写并发代码时,始终保持警惕,确保资源访问顺序的一致性,并使用适当的工具检测潜在问题。