每个 Goroutine 都要有归宿:生命周期、泄漏与退出协议
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 就值得再设计一次。