跳至内容
每个 Goroutine 都要有归宿:生命周期、泄漏与退出协议

每个 Goroutine 都要有归宿:生命周期、泄漏与退出协议

2026年6月29日·
yanlong

go f() 只负责启动,不负责回收。Goroutine 正常返回后,运行时会清理它的栈和调度状态;如果它永远卡在发送、接收、锁或系统调用上,GC 不会因为“没人关心结果了”就替你终止它。

因此,启动 goroutine 和打开文件一样,是一次资源获取。代码评审看到 go 关键字时,最先该问的不是“它并发吗”,而是“它如何结束”。

泄漏不一定表现为内存暴涨

一个泄漏的 goroutine 至少占用栈和调度元数据,还可能间接持有:

  • 请求对象与大块缓冲区;
  • channel、timer 和网络连接;
  • 锁、数据库事务或 tracing span;
  • 闭包捕获的整个对象图。

少量泄漏可能长期没有明显症状。流量增加后,goroutine 数量、堆占用和连接数一起缓慢爬升,最后表现为 GC 压力、连接池耗尽或停机超时。

最典型的泄漏:结果再也没人接收

func search(ctx context.Context, query string) (Result, error) {
    resultCh := make(chan Result)
    go func() {
        result := slowSearch(query)
        resultCh <- result
    }()

    select {
    case result := <-resultCh:
        return result, nil
    case <-ctx.Done():
        return Result{}, ctx.Err()
    }
}

调用超时后,search 返回,发送方可能永远阻塞在 resultCh <- result。修复不能只靠把 channel 改成有缓冲;容量 1 在“只有一个结果”时确实允许发送完成,却没有让底层 slowSearch 响应取消。

更完整的设计是让整个调用链接收 Context,并让发送也可取消:

func search(ctx context.Context, query string) (Result, error) {
    type outcome struct {
        value Result
        err   error
    }
    resultCh := make(chan outcome, 1)

    go func() {
        result, err := slowSearch(ctx, query)
        select {
        case resultCh <- outcome{value: result, err: err}:
        case <-ctx.Done():
        }
    }()

    select {
    case result := <-resultCh:
        return result.value, result.err
    case <-ctx.Done():
        return Result{}, ctx.Err()
    }
}

这里的缓冲区用于解除“结果恰好完成”和“调用方正在返回”的短暂耦合,Context 才负责真正结束工作。

永久接收也会泄漏

func consume(events <-chan Event) {
    go func() {
        for event := range events {
            handle(event)
        }
    }()
}

只有当 events 最终会关闭时,这个 goroutine 才有退出路径。如果 channel 的所有权跨越多个组件,没人知道谁负责关闭,就等于没有退出协议。

长期组件通常显式接收 Context:

func consume(ctx context.Context, events <-chan Event) {
    for {
        select {
        case <-ctx.Done():
            return
        case event, ok := <-events:
            if !ok {
                return
            }
            handle(event)
        }
    }
}

如果 handle 本身可能长时间阻塞,也必须把 Context 继续传下去。只在最外层 select 一次并不能让正在执行的调用可取消。

每个 goroutine 都需要一个所有者

可以把常见 goroutine 分成三类:

请求内任务

生命周期不能超过请求,应继承请求 Context,并在返回前等待结束。适合使用 errgroup.WithContext

组件后台任务

例如配置刷新、批量上报和连接保活。组件应持有 cancel 与 WaitGroup,Close 先发出取消,再等待退出:

type Refresher struct {
    cancel context.CancelFunc
    wg     sync.WaitGroup
}

func NewRefresher(parent context.Context) *Refresher {
    ctx, cancel := context.WithCancel(parent)
    r := &Refresher{cancel: cancel}
    r.wg.Add(1)
    go func() {
        defer r.wg.Done()
        r.run(ctx)
    }()
    return r
}

func (r *Refresher) Close() {
    r.cancel()
    r.wg.Wait()
}

进程级任务

监听信号、启动服务器等任务由 main 或应用容器拥有。它们仍然需要停机顺序和等待机制,不能因为“和进程同寿命”就到处使用 context.Background()

    flowchart TD
  Main[进程 Context] --> HTTP[HTTP Server]
  Main --> Worker[Worker]
  Main --> Refresh[Config Refresher]
  Stop[停止信号] --> Main
  Main -->|cancel| HTTP
  Main -->|cancel| Worker
  Main -->|cancel| Refresh
  HTTP --> Wait[等待全部退出]
  Worker --> Wait
  Refresh --> Wait
  

启动 API 应暴露停止语义

下面的 API 很可疑:

func StartMetricsReporter()

调用方不知道它是否启动 goroutine、如何停止、停机是否会丢数据。更好的形式取决于组件语义:

func RunMetricsReporter(ctx context.Context) error

// 或者
type Reporter interface {
    Start(context.Context)
    Close(context.Context) error
}

Run(ctx) error 很适合结构化并发:调用方决定是否放进 goroutine,并统一收集错误。构造函数悄悄启动后台任务则最难管理,除非返回对象的 Close 契约非常明确。

限制数量比事后排查更重要

为每个输入直接启动 goroutine,输入规模就成了调度器的负载开关:

for _, item := range items {
    go process(item)
}

即使每个任务最终都会结束,瞬时几十万个 goroutine 也会推高内存和下游并发。worker pool、信号量或 errgroup.SetLimit 应当把并发度变成显式配置。

“goroutine 很便宜”指相对线程便宜,不是免费,更不代表下游数据库和 RPC 能承受同样的并发度。

怎么发现泄漏

看趋势,不看单点

runtime.NumGoroutine() 能作为粗粒度指标,但某一时刻的数量高不代表泄漏。更有价值的是在稳定流量和操作结束后观察是否回落。

查看 goroutine profile

启用 net/http/pprof 后,可以检查阻塞栈聚合:

go tool pprof http://localhost:6060/debug/pprof/goroutine

或者获取文本栈:

/debug/pprof/goroutine?debug=2

重点找大量重复栈:阻塞在同一 channel send、网络读取或锁等待上的 goroutine。

Go 1.26 还提供实验性的 goroutineleak profile,可检测一部分已经不可能被唤醒的 goroutine;它很有帮助,但无法证明“没有报告就没有泄漏”,尤其是阻塞对象仍被全局变量引用时。

测试退出,而不是等待固定时间

给组件提供明确的 Close/Wait 后,测试可以验证停止协议。只比较测试前后的 goroutine 数量容易受到运行时和测试框架后台任务干扰。

看到 go 关键字时的审查清单

  • 谁拥有它?
  • 正常完成条件是什么?
  • 调用方提前返回时如何取消?
  • 它阻塞在发送或接收时还能否退出?
  • 谁等待它结束并收集错误?
  • 并发数量的上限在哪里?

只要其中一个问题没有答案,这个 goroutine 就值得再设计一次。

延伸阅读