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 是现场如果:
  1. 它不会被并发原语阻挡,或者
  2. 至少有一个并发原语阻挡该程序,会被另一个活运行程序引用。

在平凡情况下,未被阻塞的goroutine显然没有泄漏。在归纳情况下,基本假设是任何未泄漏的goroutine最终都可以利用它引用的并发原语来解除被这些原语阻塞的其他goroutine。

要找到所有活跃的goroutine,我们从明显存在的未阻塞goroutine开始,追踪它们持有的任何引用,即通过其局部变量,找到它们可访问的并发原语。然后我们逐步将所有被这些原语阻塞的goroutine纳入为活例,重复此过程直到发现新的活goroutine。

幸运的是,Go 运行时已经通过垃圾收集器(GC),下一步是调整GC以适应我们的目的。你可以快速比较以下图表:

GC 不需要彻底重构。Go 运行时使用并发的三色标记扫除垃圾回收器(现为绿茶变体!),所以它的作案手法已经与我们的目标完美契合。只需要做几个关键调整:

  1. 在初期阶段,常规GC标记全部goroutine(及全局变量)作为可达对象,使其永远不会被视为垃圾,即它们是马克·鲁茨.我们改成只有包含未封锁的goroutine,因为它们保证是实时的。
  2. 接着是标记阶段,GC追踪标记根(传递性)所指的对象,标记它们作为可用内存。即使我们不直接修改这一阶段,第一步的更改隐式确保GC只标记由活运行程序引用的内存。
  3. 标记阶段通过检查步骤1中未包含的所有阻塞的goroutine来最终完成。如果一个goroutine被至少一个在步骤2标记的并发原语阻塞,则将其作为标记根添加,GC从步骤2继续标记阶段。这与定义活体中的归纳步骤一致。
  4. 一旦发现了所有活goroutine,任何未被添加为标记根的goroutine状态都设置为泄露状态。
  5. 标记阶段最后一次恢复,所有泄露的 goroutine 作为标记根添加,允许 GC 标记它在常规运行中标记的所有内存。

GC 周期完成后,goroutine 泄漏分析器会像普通 goroutine 配置文件一样接收,并过滤严格泄露的 goroutine。

局限性

上述示例展示了goroutine泄密剖面的实用性。然而,垃圾回收器存在一些限制,可能导致漏漏:

  1. 记忆过度扩张:如果一个并发原语通过全局变量可运行的goroutine那么,即使该并发原语未来不再使用,阻塞其上的 goroutine 也不会被报告为泄露。通过更好地规范并发原始引用的访问,以及更清晰地划定其生命周期,可以缓解这个问题。
  2. 非标准阻挡:为正确起见,goroutine泄漏检测严格限于Go一类并发原语,包括:通道发送和接收操作(包括nil通道),阻挡select即 没有default大小写,直到,包括select无格的陈述,以及sync具体来说,是包装Mutex,RWMutex,WaitGroup以及Cond.因其他原因被阻挡的 Goroutine,例如文件和网络 IO 或直接系统调用,绝不会被视为泄露。同样适用于自定义用户定义的并发,例如自旋锁,除非它们依赖上述原语作为底层实现。
  3. 非确定性:泄漏只能在发生后被检测,但无法通过其他方式预测,因此在不稳定程序中重现和诊断泄漏仍是挑战。为了获得最佳效果,我们鼓励混合方法,使用多个层级的goroutine泄漏配置文件,包括生产层,以及配备以下设备的综合测试套件。goleak以及synctest.

性能影响

Goroutine的泄漏检测经过精心设计以最大限度降低性能影响,但仍然存在一定的代价。

虽然内存开销微乎其微,仅限于记账所需的小规模添加,但流程泄漏检测可能比普通GC慢。这可以通过一个我们称之为“菊链”的病态案例来最好说明:在这个无泄漏的例子中,可运行的goroutine G₀引用了原始P₁,阻挡了G₁,依此类推。

这意味着证明某个Pi₊₁的活率需要证明Pi的活率,这引入了两个代价:

  1. GC 标记阶段实际上是相对于 goroutine 扫描顺序序列化的,因为必须标记所有可从某个 Pi 访问的内存,才能将 Pi₊₁ 作为根节点添加。
  2. 目前,检查在每轮标记结束时检查所有阻塞的 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()

添加调用Unlockbreak解决了这个问题。

示例(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:}

发送到的包裹statusselect另一个命题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)

解决办法是在收到消息后设置一个独立的 goroutinecloseChanmonitor排干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下一次循环迭代的陈述。

上一篇文章:通用方法
博客索引

添加评论
点赞收藏
点踩分享查看原文
评论
?
参与讨论