【go】多线程编程如何识别和避免死锁,常见死锁场景分析,pprof使用指引
在Go后端面试中,死锁是多线程编程的高频考点。以下通过代码案例详解常见死锁场景及解决方案,帮助你系统化掌握:
一、经典场景1:循环等待锁(AB-BA型)
问题代码
func main() {var muA, muB sync.Mutex// 协程1:先锁A再锁Bgo func() {muA.Lock()defer muA.Unlock()time.Sleep(time.Millisecond) // 确保协程2有机会运行muB.Lock()defer muB.Unlock()fmt.Println("Goroutine 1 acquired both locks")}()// 协程2:先锁B再锁Ago func() {muB.Lock()defer muB.Unlock()time.Sleep(time.Millisecond) // 确保协程1有机会运行muA.Lock()defer muA.Unlock()fmt.Println("Goroutine 2 acquired both locks")}()time.Sleep(time.Second)
}
死锁原因
- 协程1持有A锁,请求B锁
- 协程2持有B锁,请求A锁
- 形成循环等待,双方永久阻塞
解决方案
统一加锁顺序(如按锁地址排序):
if &muA < &muB {muA.Lock()defer muA.Unlock()muB.Lock()defer muB.Unlock()
} else {muB.Lock()defer muB.Unlock()muA.Lock()defer muA.Unlock()
}
二、场景2:重复加锁(未释放锁导致递归阻塞)
问题代码
func main() {var mu sync.Mutexgo func() {mu.Lock()defer mu.Unlock()// 错误:在锁未释放时尝试再次加锁mu.Lock() defer mu.Unlock()fmt.Println("This line will never be reached")}()time.Sleep(time.Second)
}
死锁原因
Go的sync.Mutex
不可重入,同一协程重复加锁会导致自我阻塞
解决方案
使用可重入锁(需自定义实现,如通过协程ID判断):
type RecursiveMutex struct {sync.Mutexowner int64recursion int32
}func (m *RecursiveMutex) Lock() {gid := goID() // 获取当前协程IDif atomic.LoadInt64(&m.owner) == gid {m.recursion++return}m.Mutex.Lock()atomic.StoreInt64(&m.owner, gid)m.recursion = 1
}
三、场景3:无缓冲通道收发不匹配
问题代码
func main() {ch := make(chan int) // 无缓冲通道go func() {<-ch // 接收操作fmt.Println("Received from channel")}()// 错误:主协程未启动接收就关闭程序// close(ch) // 若在此关闭通道可解决问题
}
死锁原因
- 无缓冲通道要求
发送
和接收
操作必须同时就绪 - 若只有一方执行,会导致永久阻塞
解决方案
确保收发配对:
go func() {ch <- 42 // 发送数据fmt.Println("Sent to channel")
}()// 主协程接收
fmt.Println(<-ch)
四、场景4:WaitGroup计数错误(Add/Done不匹配)
问题代码
func main() {var wg sync.WaitGroup// 错误:未调用wg.Add()就启动协程go func() {defer wg.Done() // 减少计数(初始值为0,导致panic或死锁)fmt.Println("Worker goroutine")}()wg.Wait() // 永久等待,因为计数未增加
}
死锁原因
WaitGroup
的Add(n)
必须在启动协程前调用- 若直接调用
Done()
,会导致内部计数为负数,触发panic或死锁
解决方案
正确管理计数:
wg.Add(1) // 先增加计数
go func() {defer wg.Done()fmt.Println("Worker goroutine")
}()
wg.Wait()
五、场景5:通道关闭时机错误(close后继续发送)
问题代码
func main() {ch := make(chan int)var wg sync.WaitGroupwg.Add(1)go func() {defer wg.Done()ch <- 42time.Sleep(time.Second) // 模拟耗时操作ch <- 43 // 错误:通道已关闭,panic}()wg.Wait() // 错误:主协程在此等待,未接收数据close(ch) // 通道在所有数据发送前关闭
}
死锁原因
- 协程在通道关闭后继续发送数据,触发panic
wg.Wait()
在数据发送完成前执行,导致通道提前关闭
解决方案
先完成发送,再关闭通道:
wg.Add(1)
go func() {defer wg.Done()ch <- 42ch <- 43
}()// 主协程接收所有数据后再关闭
wg.Wait()
close(ch)
六、场景6:select无default分支且所有case阻塞
问题代码
func main() {ch1 := make(chan int)ch2 := make(chan int)select {case <-ch1:fmt.Println("Received from ch1")case <-ch2:fmt.Println("Received from ch2")// 错误:无default分支,所有case阻塞导致死锁}
}
死锁原因
select
中所有case都阻塞(无缓冲通道无数据)- 无
default
分支,导致整个select永久阻塞
解决方案
添加非阻塞分支:
select {
case <-ch1:fmt.Println("Received from ch1")
case <-ch2:fmt.Println("Received from ch2")
default:fmt.Println("No data received, continuing...")
}
七、死锁检测工具
- Go内置死锁检测:运行时自动检测典型死锁(如循环等待锁),程序会panic并输出堆栈信息
- 静态分析工具:
go vet
:检测常见同步错误staticcheck
:检查WaitGroup使用不当等问题
- pprof + race detector:
go run -race main.go # 动态检测数据竞争和潜在死锁
应答模板
问题:如何识别和避免Go中的死锁?
回答框架:
-
识别方法:
- 利用Go运行时的死锁检测机制(如循环等待锁会触发panic)
- 通过pprof分析协程阻塞情况
-
常见场景:
- 循环等待锁(AB-BA型)
- 重复加锁(sync.Mutex不可重入)
- 通道收发不匹配(无缓冲通道)
- WaitGroup计数错误
-
预防策略:
- 统一加锁顺序
- 使用带缓冲通道
- 遵循“Add先于协程启动,Done配对”原则
- 合理设计select的default分支
使用pprof分析的详细方案
一、pprof简介
pprof
是Go语言自带的性能分析工具,能够帮助开发者收集CPU、内存、goroutine等资源的使用情况,生成性能报告并提供可视化功能。它提供了全面的性能分析能力,是排查性能瓶颈、优化代码的重要工具。
二、pprof使用场景
- CPU分析:查看哪些函数消耗了大量的CPU时间,定位CPU密集型操作,优化算法或减少不必要的计算。
- 内存分析:检查哪些对象占用了大量内存,分析内存分配频率,排查内存泄漏问题,合理管理内存使用。
- Goroutine分析:查看Goroutines的状态及堆栈信息,排查死锁、资源竞争等并发问题,优化并发逻辑。
- 阻塞分析:跟踪goroutines被阻塞的位置,帮助找出锁竞争和其他阻塞行为,提升程序响应速度。
三、在Go中启用pprof
在Go应用中启用pprof
,只需简单引入内置的net/http/pprof
包,并启动HTTP服务。以下是示例代码:
package mainimport ("fmt""net/http"_ "net/http/pprof""sync"
)func main() {// case1()// case2()handleFunc()
}func handleFunc() {http.HandleFunc("/test", func(writer http.ResponseWriter, request *http.Request) {go case2()})http.ListenAndServe(":8080", nil)
}func case2() {wg := sync.WaitGroup{}ch := make(chan struct{})wg.Add(1)go func() {defer wg.Done()fmt.Println("goroutine 1")for {select {case <-ch:fmt.Println("quit goroutine 1")return}}// 上面的for select可以改造成range ch,但是会造成还没来得及range监听ch,close就已经触发的情况// 可以在下面close之前先阻塞写入,确保range 监听// for range ch {// fmt.Println("quit goroutine 1")// break// }}()ch <- struct{}{}close(ch)wg.Wait()
}func case1() {var wg sync.WaitGroupwg.Add(1)go func() {fmt.Println("worker finshed")wg.Done()}()wg.Wait()
}
上述代码将在localhost:8080
启动一个HTTP服务器,可通过浏览器访问不同的pprof分析页面。
四、访问pprof分析页面
启动服务后,在浏览器中访问http://localhost:8080/debug/pprof/
,会看到如下页面,提供了多种分析报告入口:
/debug/pprof/
:所有分析报告入口汇总。/debug/pprof/profile
:生成CPU性能报告,默认进行30秒的采样。/debug/pprof/heap
:生成内存分配报告,用于了解内存使用情况。/debug/pprof/goroutine
:查看所有Goroutines的当前状态和堆栈信息。/debug/pprof/block
:查看goroutines阻塞情况。seconds=5
:获取程序执行的trace日志(可自行设置秒数)。
我们实际操作一下,go run
之后访问localhost:8080/debug/pprof
可以看到这里一开始出现了三个协程,但是我只开了一个case2,为啥是三个?,点进去发现
这里可以看到有6 threadcreate
这指的是程序创建的操作系统线程,Go 程序采用 M:N 调度模型(M 个 Goroutine 映射到 N 个操作系统线程)具体可参见我写的go语言的GMP调度,解释协程和线程的调度关系 https://blog.csdn.net/m0_74282926/article/details/147342381
- threadcreate 指标反映了程序创建操作系统线程的频率,用于诊断过度并发或阻塞问题。
正常范围:对于简单 HTTP 服务,启动时创建 3-5 个线程属于正常现象。
异常情况:若线程数持续增长或远高于预期,需检查是否存在:- 无限制的 Goroutine 创建。
- 频繁的阻塞系统调用。
- 锁竞争或通道阻塞。
通过使用连接池避免频繁创建线程,是一个有效的减少线程数量的方式
五、也可以在命令行使用go tool pprof命令分析报告
(一)CPU性能分析
在代码运行的同时,使用以下命令进行CPU性能分析:
go tool pprof http://localhost:8080/debug/pprof/profile
执行上述命令会下载CPU性能报告,并进入交互式命令行。常用命令如下:
-
top
:显示消耗CPU时间最多的函数列表。
-
list (函数名)
:显示指定函数的源码及其耗时。
-
web
:生成图形化的性能分析报告(需要Graphviz支持,若未安装会提示安装)。
https://graphviz.org/download/#windows
(二)内存分析
使用以下命令分析内存:
go tool pprof http://localhost:8080/debug/pprof/heap
同样可使用top
和list
命令查看内存分配最多的地方。
https://github.com/0voice