Goroutine泄漏的Profile分析
Go 博客
Goroutine 泄露配置文件
弗拉德·赛奥克
2026年9月2日
去吧并发特性强大且易用,但同样的简单性有时也会让经验丰富的开发者犯错。幸运的是,Go 生态系统配备了有用的调试工具,例如,比赛检测器,但即使是现有工具也可能忽略一些并发错误,比如本文主题goroutine 泄露.
Goroutine通过共享的并发原语(如通道、锁和等待组)同步或交换信息。在通信过程中,goroutine 通常封锁在这些原语上,比如等待满足某个条件;常见的例子包括等待获取持有的互斥组,或通过通道接收消息。Goroutine 还可以阻止操作系统操作,比如从网络套接字或文件读取。
如果一个goroutine被阻塞,我们可以认为它泄露了,但解除阻塞所需的条件永远无法满足。随着时间推移,泄漏的goroutine的累积会通过过度的内存使用(泄露的goroutine本身或其引用的内存)以及垃圾回收器带来的CPU使用率,降低性能GOMEMLIMIT正在使用。
Goroutine泄露通常非常难以被发现。在单元测试中,最重要的突破包括开源库goleak可以对单个测试进行仪器化,在测试结束后,将任何未终止的流程标记为可疑。同样,Go 1.25引入了synctest包装标准库;它可以通过让 Go 开发者更好地控制并发事件的顺序,从而可靠地测试难以复现的场景,从而显著提升并发代码单元测试的质量。
遗憾的是,这两种方法都无法检测生产系统中的goroutine泄漏,尤其是在较大规模时,这些泄漏可能出现测试未曾考虑的行为。goroutine配置文件是一种初步的方法,用于检查阻塞过多goroutine的操作,或分析增长趋势。然而,goroutine配置文件无法区分泄漏的goroutine和设计上因微服务流量增加而暂时阻塞的大量goroutine。同样,数量较少的泄漏也可能多年未被发现地通过。
Go 1.27引入了Goroutine 泄漏分析器,这是一种灵活且轻量级的机制,用于在运行的Go程序中发现goroutine泄漏,包括生产系统。与以往需要人工分析的方法不同,该机制非常精确,几乎不产生误报。权衡在于它仅限于部分goroutine泄露:被永久阻塞在通道上的goroutine或在sync包装.幸运的是,这已经涵盖了非常大部分的goroutine泄露,正如我们将在示例中看到的那样。
在接下来的章节中,我们将展示如何使用该功能,随后展示一些可检测的泄漏示例,以及底层实现和权衡的描述。
示例:并行工作者
考虑一个同时处理工作项的函数:
type result struct {
res workResult
err error
}
func processWorkItems(ws []workItem) ([]workResult, error) {
// Process work items in parallel, aggregating results in ch.
ch := make(chan result)
for _, w := range ws {
go func() {
res, err := processWorkItem(w)
ch <- result{res, err}
}()
}
// Collect the results from ch, or return an error if one is found.
var results []workResult
for range len(ws) {
r := <-ch
if r.err != nil {
// This early return may cause goroutine leaks.
return nil, r.err
}
results = append(results, r.res)
}
return results, nil
}
因为ch是一个未缓冲的通道,每个工作者goroutine在发送结果时都会被阻挡,直到主goroutine从该通道接收到结果。如果processWorkItems由于错误提前返回,接收环路终止,所有剩余的发送方 Goroutine 永久阻塞。
这个例子典型地反映了真实 Go 程序中常见的一个错误,包括 Uber 生产服务。让我们看看如何通过使用新的 Goroutine 泄漏分析器来发现这些泄漏。
使用goroutine泄密分析器调试
该档案可通过runtime/pprof包装,作为goroutineleak配置文件类型,或者通过安装由net/http/pprof包装.如果你已经有过net/http/pprof在你的服务中设置好后,你就不需要做其他事情了!该配置文件将自动开放供收集。/debug/pprof/goroutineleak端点安装在处理器安装的主机和端口上。
让我们把并发漏洞放在上下文中,并设置net/http/pprof包裹。这样你自己试试!
package main
import (
"errors"
"log"
"net/http"
_ "net/http/pprof"
"time"
)
type workItem int
type workResult int
func processWorkItem(w workItem) (workResult, error) {
time.Sleep(10 * time.Millisecond)
if w == 5 {
return 0, errors.New("simulated error")
}
return workResult(w * 2), nil
}
type result struct {
res workResult
err error
}
func processWorkItems(ws []workItem) ([]workResult, error) {
ch := make(chan result)
for _, w := range ws {
go func() {
res, err := processWorkItem(w)
ch <- result{res, err}
}()
}
var results []workResult
for range len(ws) {
r := <-ch
if r.err != nil {
return nil, r.err
}
results = append(results, r.res)
}
return results, nil
}
func main() {
// Start pprof server
go func() {
log.Println(http.ListenAndServe("localhost:6060", nil))
}()
// Repeatedly trigger the leak
for {
items := []workItem{1, 2, 3, 4, 5, 6, 7, 8, 9, 10}
_, err := processWorkItems(items)
if err != nil {
log.Printf("Error processing items: %v", err)
}
time.Sleep(time.Second)
}
}
先构建上面的程序,然后运行它:
$ go build -o leaky
$ ./leaky
收集档案
程序很快就会开始累积泄露信息,你可以通过 http://localhost:6060/debug/pprof 的网页界面查看。
或者,你也可以用以下方式收集 goroutine 的泄漏曲线curl然后用go tool pprof:
$ curl http://localhost:6060/debug/pprof/goroutineleak > leak.prof
$ go tool pprof leak.prof
Type: goroutineleak
Time: 2026-03-01 13:19:49 UTC
Entering interactive mode (type "help" for commands, "o" for options)
(pprof) list processWorkItems
Total: 116
ROUTINE ======================== main.processWorkItems.func1 in .../main.go
0 116 (flat, cum) 100% of Total
. . 31: go func() {
. . 32: res, err := processWorkItem(w)
. 116 33: ch <- result{res, err}
. . 34: }()
该配置文件揭示了泄露的goroutinech <- result{res, err}(第33行),确定了罪魁祸首操作。值得注意的是,程序运行时间越长,泄露的goroutine数量越多。
解决泄漏问题
这种泄漏可以通过提供来简单修复cha缓冲区:
ch := make(chan result, len(ws))
这使得所有工作项的goroutine在发生过早返回时都能发送消息而不阻塞processWorkItems.
我们在 中列出更多真实世界的例子。
实现
本节面向那些对 goroutine 泄漏分析器内部泄漏检测工作原理感兴趣的人。关于性能开销和限制的详细信息,请跳到 。
核心概念
让我们先从一个初步观察开始:如果一个 goroutine 被某个其他 goroutine 无法访问的并发原语阻挡(在这里是通过内存中的引用),那么显然它是泄露的。这已经是一个强有力的线索,我们可以进一步推广为定义 goroutine 的定义不是泄漏,我们称之为活体.我们正式定义活度,一种归纳性质如下:
goroutine 是现场如果:
- 它不会被并发原语阻挡,或者
- 至少有一个并发原语阻挡该程序,会被另一个活运行程序引用。
在平凡情况下,未被阻塞的goroutine显然没有泄漏。在归纳情况下,基本假设是任何未泄漏的goroutine最终都可以利用它引用的并发原语来解除被这些原语阻塞的其他goroutine。
要找到所有活跃的goroutine,我们从明显存在的未阻塞goroutine开始,追踪它们持有的任何引用,即通过其局部变量,找到它们可访问的并发原语。然后我们逐步将所有被这些原语阻塞的goroutine纳入为活例,重复此过程直到发现新的活goroutine。
幸运的是,Go 运行时已经通过垃圾收集器(GC),下一步是调整GC以适应我们的目的。你可以快速比较以下图表:
GC 不需要彻底重构。Go 运行时使用并发的三色标记扫除垃圾回收器(现为绿茶变体!),所以它的作案手法已经与我们的目标完美契合。只需要做几个关键调整:
- 在初期阶段,常规GC标记全部goroutine(及全局变量)作为可达对象,使其永远不会被视为垃圾,即它们是马克·鲁茨.我们改成只有包含未封锁的goroutine,因为它们保证是实时的。
- 接着是标记阶段,GC追踪标记根(传递性)所指的对象,标记它们作为可用内存。即使我们不直接修改这一阶段,第一步的更改隐式确保GC只标记由活运行程序引用的内存。
- 标记阶段通过检查步骤1中未包含的所有阻塞的goroutine来最终完成。如果一个goroutine被至少一个在步骤2标记的并发原语阻塞,则将其作为标记根添加,GC从步骤2继续标记阶段。这与定义活体中的归纳步骤一致。
- 一旦发现了所有活goroutine,任何未被添加为标记根的goroutine状态都设置为泄露状态。
- 标记阶段最后一次恢复,所有泄露的 goroutine 作为标记根添加,允许 GC 标记它在常规运行中标记的所有内存。
GC 周期完成后,goroutine 泄漏分析器会像普通 goroutine 配置文件一样接收,并过滤严格泄露的 goroutine。
局限性
上述示例展示了goroutine泄密剖面的实用性。然而,垃圾回收器存在一些限制,可能导致漏漏:
- 记忆过度扩张:如果一个并发原语通过全局变量或可运行的goroutine那么,即使该并发原语未来不再使用,阻塞其上的 goroutine 也不会被报告为泄露。通过更好地规范并发原始引用的访问,以及更清晰地划定其生命周期,可以缓解这个问题。
- 非标准阻挡:为正确起见,goroutine泄漏检测严格限于Go一类并发原语,包括:通道发送和接收操作(包括
nil通道),阻挡select即 没有default大小写,直到,包括select无格的陈述,以及sync具体来说,是包装Mutex,RWMutex,WaitGroup以及Cond.因其他原因被阻挡的 Goroutine,例如文件和网络 IO 或直接系统调用,绝不会被视为泄露。同样适用于自定义用户定义的并发,例如自旋锁,除非它们依赖上述原语作为底层实现。 - 非确定性:泄漏只能在发生后被检测,但无法通过其他方式预测,因此在不稳定程序中重现和诊断泄漏仍是挑战。为了获得最佳效果,我们鼓励混合方法,使用多个层级的goroutine泄漏配置文件,包括生产层,以及配备以下设备的综合测试套件。
goleak以及synctest.
性能影响
Goroutine的泄漏检测经过精心设计以最大限度降低性能影响,但仍然存在一定的代价。
虽然内存开销微乎其微,仅限于记账所需的小规模添加,但流程泄漏检测可能比普通GC慢。这可以通过一个我们称之为“菊链”的病态案例来最好说明:在这个无泄漏的例子中,可运行的goroutine G₀引用了原始P₁,阻挡了G₁,依此类推。
这意味着证明某个Pi₊₁的活率需要证明Pi的活率,这引入了两个代价:
- GC 标记阶段实际上是相对于 goroutine 扫描顺序序列化的,因为必须标记所有可从某个 Pi 访问的内存,才能将 Pi₊₁ 作为根节点添加。
- 目前,检查在每轮标记结束时检查所有阻塞的 goroutine,最坏情况下 GC 周期内 O(n²) 步,其中 n 是总 goroutine 数。
虽然第二点最终可以优化,但第一点是泄漏检测的内在限制,无法绕过。
无论如何,我们提醒读者,除非通过运行时标志进行其他配置,GC仍然与用户代码并行运行。此外,如果在某个时间点能观察到goroutine泄漏,那么在同一执行过程中的未来任何时刻也能被观察到。因此,定期的分析基础设施可以调整分析频率,例如每4小时一次,以几乎不消耗泄漏检测能力,从而最大限度地减少开销。
致谢
Goroutine泄漏检测是奥胡斯大学、圣路易斯华盛顿大学和Uber合作研究的成果,具体内容如下所示“通过垃圾回收进行动态部分死锁检测与恢复”(Saioc 等,ASPLOS 2025)。
从学术原型到实际 Go 功能的转变,得益于 Google Go 团队的 Michael Knyszek 和 Michael Pratt,以及 PJ Malloy(@thepudds).
其他例子
以下是导致泄漏的编码模式,如工业级代码库和开源项目中观察到的,按复杂度递增排列。
你可以快速测试一下Goroutine泄漏检测器围棋游乐场同时也要尝试自己的泄漏设备。
示例:双重发送
一些最简单的泄漏发生在通过一个通道发送的消息超过预期时。下面,一个goroutine预计通过一个未缓冲的通道向主goroutine发送一条消息。然而,return在错误情况下发送操作后缺少该语句。因此,发送方会尝试发送两条消息,导致泄漏。
func DoubleSend() {
ch := make(chan any)
go func(err error) {
if err != nil {
// In case of an error, send nil.
ch <- nil
// Return statement is missing.
}
// Otherwise, continue with normal behaviour.
// This send is still executed, which causes a leak in the error case.
ch <- struct{}{}
}(fmt.Errorf("error"))
// Receive only one message.
<-ch
}
虽然该档案并未明确突出缺失者return作为原因,至少它通过高亮显示漏水发送操作,指引你找到故障函数。
(pprof) list DoubleSend
Total: 1
ROUTINE ======================== main.DoubleSend.func1 in .../main.go
0 1 (flat, cum) 100% of Total
. . 118: go func(err error) {
. . 119: if err != nil {
. . 121: ch <- nil
. . 123: }
. 1 126: ch <- struct{}{}
. . 127: }(fmt.Errorf("error"))
. . 129: <-ch
这种泄漏可以通过添加一个return在错误情况下发送操作后调用语句。
示例:提前回归
反过来的情况同样常见,接收方在某些控制流路上省略通信,这实际上是入门示例的简化版。
// Incoming error simulates an error produced internally.
func EarlyReturn(err error) {
ch := make(chan any)
// Create a worker goroutine.
go func() {
// Send something to the channel.
// Leaks if the parent goroutine terminates early.
ch <- struct{}{}
}()
if err != nil {
// The parent goroutine quits too early in case of an error.
// Sender leaks.
return
}
// Receive is only executed if there is no error.
<-ch
}
goroutine泄露通过以下配置文件暴露:
ROUTINE ======================== main.EarlyReturn.func1 in .../main.go
0 1 (flat, cum) 100% of Total
. . 140: go func() {
. 1 143: ch <- struct{}{}
. . 144: }()
. . 145:
. . 146: if err != nil {
泄漏可以通过给予来解决ch一个大小为1的缓冲区。
示例:暂停
该变体的变体提前归来上述模式涉及上下文和非确定性选择(select声明):
func Timeout(ctx context.Context) {
// An unbuffered channel is used to coordinate
// a worker and parent thread
ch := make(chan any)
// Create worker goroutine
go func() {
// Perform some work then signal to the parent thread.
ch <- struct{}{}
}()
// Wait for message from worker or context
// to be cancelled or timed out.
select {
case <-ch: // Receive message from worker
case <-ctx.Done():
// Sender leaks because there is no
// future rendezvous over the channel.
}
}
如果上下文在发送方与父端同步前被取消,发送方将泄露:
(pprof) list Timeout
Total: 10
ROUTINE ======================== main.Timeout.func1.1 in .../main.go
0 10 (flat, cum) 100% of Total
. . 198: go func() {
. 10 201: ch <- struct{}{}
. . 202: }()
与前例相同,修复方法是给出大小为1的通道缓冲区。
示例:通道上距离但未闭合
通道迭代通过range允许你反复从循环中的信道接收值。一旦信道关闭,且所有已排队到该信道缓冲区的值都被接收完毕,环路就会退出。
重要的是,如果通道从未关闭, arange循环会永久阻挡执行中的goroutine。省略close操作是一个常见错误,如下所示:
// Incoming list of items and the number of workers.
func noCloseRange(list []any, workers int) {
// Create a channel that distributes work items.
ch := make(chan any)
// Create the worker goroutines.
for i := 0; i < workers; i++ {
go func() {
// Each worker pulls items from the channel
// and then processes it.
for item := range ch {
// Process each item
_ = item
}
}()
}
// Queue items to the workers by using the channel.
for _, item := range list {
// The parent leaks by sending an item if workers == 0
// or if all the workers panic, but the panic is recovered.
ch <- item
}
// Otherwise, the channel is never closed, so workers
// leak once there are no more items left to process.
}
...
go noCloseRange([]any{1, 2, 3}, 3) // Leaks all 3 workers
此类程序的 goroutine 泄漏配置文件应包括以下内容:
Type: goroutineleak
(pprof) list noCloseRange.func1
Total: 4
ROUTINE ======================== main.noCloseRange.func1 in .../main.go
0 3 (flat, cum) 75.00% of Total
. . 82: go func() {
. 3 84: for item := range ch {
. . 86: _ = item
. . 87: }
. . 88: }()
我们看到三个工人被堵在range ch操作,提供了泄漏原因的充分提示。泄漏问题可以通过在发送完毕后关闭通道来解决:
for _, item := range list {
ch <- item
}
// All items have been sent. It is now safe to close.
close(ch)
额外奖励!眼尖的读者可能在这个例子中发现了另一个潜在的泄露,如果工人数量被错误设置为零,会导致父发送方泄露:
go noCloseRange([]any{1, 2, 3}, 0) // Sender leaks with 0 workers
这也被以下剖面所体现:
(pprof) list noCloseRange$
Total: 4
ROUTINE ======================== main.noCloseRange in .../main.go
0 1 (flat, cum) 25.00% of Total
. . 76:func noCloseRange(list []any, workers int) {
...
. . 92: for _, item := range list {
. 1 95: ch <- item
. . 96: }
虽然workers > 0可以假设在现实生产系统中成立,但goroutine泄漏剖面仍可用于隐式监控偶然违规,无需保守workers <= 0支票。
示例:方法契约违规
迄今为止所见的模式在词汇范围上相对受限。然而,随着功能分散在函数、方法和包之间,且实现被接口模糊,手动检测泄漏的难度大幅增加。
本节中展示了此类情况,采用了习惯worker嵌入两个信道场的类型,ch以及done并创建一个循环的goroutine,其Start一种能从两个通道读取的方法,使用select语句。该goroutine只能通过通过done该通道被Stop方法。
该Start该方法可以被调用任意次数,但如果至少被调用一次,Stop最终应该会被打电话。
因此,Start以及Stop形成一个隐式契约,规定方法应调用的顺序。违反该契约可能导致不良行为,即goroutine泄露:
func MethodContractViolation() {
items := make([]any, 10)
// Create a new worker
w := NewWorker()
// Start worker
w.Start()
// Operate on worker
for _, item := range items {
w.AddToQueue(item)
}
// Exits without calling ’Stop’.
}
type worker struct {
ch chan any
done chan any
}
type Worker interface {
Start()
Stop()
AddToQueue(item any)
}
func NewWorker() Worker {
return &worker{
ch: make(chan any),
done: make(chan any),
}
}
// Start spawns a background goroutine that extracts items pushed to the queue.
func (w *worker) Start() {
go func() {
for {
select {
case <-w.ch: // Normal workflow
case <-w.done:
return // Shut down
}
}
}()
}
func (w *worker) Stop() {
// Allows goroutine created by Start to terminate
close(w.done)
}
func (w *worker) AddToQueue(item any) {
w.ch <- item
}
这一问题在实际操作中更加严重,因为此类自定义类型仅作为接口导出,在此例中,通过非描述性Worker类型。客户端甚至可能不知道底层实现,因此在不知情的情况下违反了隐含的契约。
幸运的是,索取一个常规漏水剖析可以发现缺陷:
(pprof) list Start
Total: 1
ROUTINE ======================== main.(*worker).Start.func1 in .../main.go
0 1 (flat, cum) 100% of Total
. . 266: go func() {
. . 267: for {
. 1 268: select {
. . 269: case <-w.ch:
. . 270: case <-w.done:
. . 271: return
自然,解决方法是沿着线索追踪到Start调用并添加调用Stop.
举例(蟑螂):缺少解锁
如下示例取自蟑螂数据库.它涉及在循环中获取并释放锁,但在执行前忘记解锁break陈述:
type Gossip struct {
mu sync.Mutex
closed bool
}
func (g *Gossip) bootstrap() {
for {
g.mu.Lock()
if g.closed {
// Missing g.mu.Unlock
break
}
g.mu.Unlock()
}
}
func Cockroach584() {
g := &Gossip{
closed: true,
}
// ...
g.bootstrap()
g.bootstrap() // Causes a leak
}
在这种情况下,当无法获得锁时,goroutine会泄漏。
(pprof) list Gossip
Total: 1
ROUTINE ======================== main.(*Gossip).bootstrap in .../main.go
0 1 (flat, cum) 100% of Total
. . 165:func (g *Gossip) bootstrap() {
. . 166: for {
. 1 167: g.mu.Lock()
. . 168: if g.closed {
. . 170: break
. . 171: }
. . 172: g.mu.Unlock()
添加调用Unlock在break解决了这个问题。
示例(etcd):意外信道操作顺序
就是这样示例, 在etcd,展示了通道操作之间意外的排序如何导致goroutine泄漏:
type node struct {
status chan chan struct{}
stop chan struct{}
done chan struct{}
}
func (n *node) Status() struct{} {
c := make(chan struct{})
n.status <- c
return <-c
}
func (n *node) run() {
for {
select {
case c := <-n.status:
c <- struct{}{}
case <-n.stop:
close(n.done)
return
}
}
}
func (n *node) Stop() {
select {
case n.stop <- struct{}{}:
case <-n.done:
return
}
<-n.done
}
func Etcd6857() {
n := &node{
status: make(chan chan struct{}),
stop: make(chan struct{}),
done: make(chan struct{}),
}
go n.run()
go n.Status()
go n.Stop()
}
该run方法触发一个循环,期望通过status通道(通过调用Status方法)。同时,它也可以通过stop通道(通过Stop方法),此时它关闭了done通道和出口。该Stop方法本身随后等待接收消息done,该 被解封一次done关闭。
如果发生run,Status, 和Stop方法并发运行。该Stop以及rungoroutine 可以同步和退出,而无需收到Status导致它永久阻塞。
(pprof) list Status
Total: 8
ROUTINE ======================== main.(*node).Status in .../main.go
0 8 (flat, cum) 100% of Total
. . 16:func (n *node) Status() struct{} {
. . 17: c := make(chan struct{})
. 8 18: n.status <- c
. . 19: return <-c
. . 20:}
发送到的包裹status在select另一个命题case分支尝试接收消息done允许goroutine运行Status如果它以 a 的条件输掉比赛,则优雅地退出Stop叫。
示例(Kubernetes):通道与互斥组之间的相互阻塞
就是这样示例发生在Kubernetes,由于通道和锁的混合:
type Connection struct {
closeChan chan bool
}
type idleAwareFramer struct {
resetChan chan bool
writeLock sync.Mutex
conn *Connection
}
func (i *idleAwareFramer) monitor() {
var resetChan = i.resetChan
for range i.conn.closeChan {
i.writeLock.Lock()
close(resetChan)
i.resetChan = nil
i.writeLock.Unlock()
break
}
}
func (i *idleAwareFramer) WriteFrame() {
i.writeLock.Lock()
defer i.writeLock.Unlock()
if i.resetChan == nil {
return
}
i.resetChan <- true
}
func NewIdleAwareFramer() *idleAwareFramer {
return &idleAwareFramer{
resetChan: make(chan bool),
conn: &Connection{
closeChan: make(chan bool),
},
}
}
func Kubernetes6632() {
i := NewIdleAwareFramer()
go func() {
i.conn.closeChan <- true
}()
go i.monitor()
go i.WriteFrame()
}
goroutine 运行WriteFrame可能获得空闲感知的帧锁,然后通过发送消息resetChan通道,而monitorgoroutine 等待通过closeChan通道。一旦消息发出,monitorGoRoutine 会尝试获取相同的锁。然而,由于没有任何流量resetChan发送操作会永久阻塞,阻止monitorGoroutine 从释放锁中获得。这反过来导致两个 GoRoutine 都泄漏。
(pprof) list AwareFramer
Total: 200
ROUTINE ======================== main.(*idleAwareFramer).WriteFrame in .../main.go
0 100 (flat, cum) 50.00% of Total
. . 32:func (i *idleAwareFramer) WriteFrame() {
. . 33: i.writeLock.Lock()
. . 34: defer i.writeLock.Unlock()
. . 35: if i.resetChan == nil {
. . 36: return
. . 37: }
. 100 38: i.resetChan <- true
. . 39:}
ROUTINE ======================== main.(*idleAwareFramer).monitor in .../main.go
0 100 (flat, cum) 50.00% of Total
. . 21:func (i *idleAwareFramer) monitor() {
. . 22: var resetChan = i.resetChan
. . 23: for range i.conn.closeChan {
. 100 24: i.writeLock.Lock()
. . 25: close(resetChan)
解决办法是在收到消息后设置一个独立的 goroutinecloseChan在monitor排干resetChan然后试图获得锁。
例子(莫比):滥用sync.WaitGroup
type Manager struct {
plugins []int
}
func (pm *Manager) init() {
var group sync.WaitGroup
group.Add(len(pm.plugins))
for _, p := range pm.plugins {
go func(p int) {
defer group.Done()
}(p)
group.Wait() // Block here
}
}
func Moby25384() {
pm := &Manager{
plugins: []int{1, 2},
}
go pm.init()
}
该group等待组根据插件管理器中持有的插件数量递增计数器pm然后对每个插件进行迭代,生成一个 goroutine。每个 goroutine 在完成其任务后会递减计数器Done方法。然而,group错误地调用Wait在循环体内部,而不是在它之后!这会导致任何运行init当管理器有多个插件需要泄露时,方法。
(pprof) list init
Total: 1
ROUTINE ======================== main.(*Manager).init in .../main.go
0 1 (flat, cum) 100% of Total
. . 17: group.Add(len(pm.plugins))
. . 18: for _, p := range pm.plugins {
. . 19: go func(p int) {
. . 20: defer group.Done()
. . 21: }(p)
. 1 22: group.Wait() // Block here
. . 23: }
这可以通过移动Wait在环之外。
示例(Moby):通道与互斥组之间的相互阻塞
type (
State struct {
Health *Health
}
Container struct {
sync.Mutex
State *State
}
Store struct {
ctr *Container
}
Daemon struct {
containers Store
}
Health struct {
stop chan struct{}
}
)
func (d *Daemon) StateChanged() {
c := d.containers.ctr
c.Lock()
d.updateHealthMonitorElseBranch(c)
defer c.Unlock()
}
func (d *Daemon) updateHealthMonitorElseBranch(c *Container) {
c.State.Health.CloseMonitorChannel()
}
func (s *Health) CloseMonitorChannel() {
if s.stop != nil {
s.stop <- struct{}{}
}
}
func monitor(c *Container, stop chan struct{}) {
for {
select {
case <-stop:
return
default:
handleProbeResult(c)
}
}
}
func handleProbeResult(c *Container) {
c.Lock()
defer c.Unlock()
// Additional work...
}
func NewDaemonAndContainer() (*Daemon, *Container) {
c := &Container{
State: &State{&Health{
stop: make(chan struct{}),
}},
}
d := &Daemon{Store{c}}
return d, c
}
func Moby28462() {
d, c := NewDaemonAndContainer()
go monitor(c, c.State.Health.stop)
go d.StateChanged()
}
goroutine调用StateChanged可以获取守护进程存储容器的锁,然后调用updateHealthMonitorElseBranch守护进程中的方法,该守护进程试图通过stop容器的通道。然而,goroutine 正在运行monitor可能无法接收到消息stop如果消息尚未在飞行中,则通过选择default案件select命题。这将促使它尝试获取已经被StateChangedGoroutine导致两个Goroutine都泄露了。
(pprof) list .CloseMonitorChannel
Total: 2
ROUTINE ======================== main.(*Health).CloseMonitorChannel in .../main.go
0 1 (flat, cum) 50.00% of Total
. . 66:func (s *Health) CloseMonitorChannel() {
. . 67: if s.stop != nil {
. 1 68: s.stop <- struct{}{}
. . 69: }
. . 70:}
(pprof) list main.handleProbeResult
Total: 2
ROUTINE ======================== main.handleProbeResult in .../main.go
0 1 (flat, cum) 50.00% of Total
. . 83:func handleProbeResult(c *Container) {
. 1 84: c.Lock()
. . 85: // Additional work...
. . 86: defer c.Unlock()
. . 87:}
解决办法是关闭stop通道而不是通过通道发送消息。由于关闭通道不是阻塞操作,因此StateChangedgoroutine 随后能够解除锁。反过来,这会解除monitorGoroutine,现在可以通过选择未阻塞来终止<-stop格分支在select下一次循环迭代的陈述。