Go服务里的goroutine泄漏,最麻烦的地方在于它经常不像故障。
进程还活着,接口还能返回,CPU可能也没有明显飙升。只是内存慢慢涨,GC越来越忙,某些请求偶尔变慢,重启之后一切又恢复正常。等团队开始怀疑“是不是哪里有goroutine没退出”时,通常已经很难从日志里还原当时的请求、取消、超时和后台任务关系。
AI Agent服务把这个问题放大了。一次对话请求可能同时包含模型流式输出、工具调用、检索、重排、审计、token统计、回调通知和超时控制。为了让整体延迟可控,工程上很自然会把这些步骤拆成多个goroutine。只要其中一个分支提前返回,另一个分支还在往无人接收的channel里写,泄漏就悄悄发生了。
Go 1.26增加的实验性goroutineleak profile,正好补上了这个诊断空位:它不是告诉你“现在有多少goroutine”,而是尝试找出那些已经不可能再被唤醒的goroutine。这件事的价值,不在于它让泄漏问题一键消失,而在于它把很多过去只能靠经验、压测和猜测定位的问题,变成了可以在测试、CI和线上诊断里主动观测的信号。

以前我们怎么找goroutine泄漏
最常见的办法,是看goroutine数量。
before := runtime.NumGoroutine()
runScenario()
time.Sleep(200 * time.Millisecond)
after := runtime.NumGoroutine()
if after > before {
t.Fatalf("goroutine count increased: before=%d after=%d", before, after)
}
这种写法简单,但误差很大。很多服务本来就有后台worker、连接池、指标采集、trace exporter和定时任务。测试之间也可能共享runtime状态。数量变多不一定代表泄漏,数量没变也不代表没有泄漏。更关键的是,数字不会告诉你哪个goroutine已经失去唤醒路径。
另一种办法是抓普通goroutine profile:
curl -s 'https://127.0.0.1:6060/debug/pprof/goroutine?debug=2'
这个profile会把当前所有goroutine的栈打出来。它适合排查“程序卡在哪里”,但对泄漏诊断并不总是友好。一个高并发服务里,正常等待的goroutine、正在处理请求的goroutine、后台worker和真正泄漏的goroutine会混在一起。你需要靠上下文判断哪些栈“应该还活着”,哪些栈“已经没人能唤醒”。
这正是goroutineleak profile想解决的问题。
Go 1.26改变了什么
Go 1.26在runtime/pprof里加入了一个新的profile名称:goroutineleak。它目前需要显式打开实验开关:
GOEXPERIMENT=goroutineleakprofile go test ./...
如果服务引入了net/http/pprof,打开这个实验后,也可以通过HTTP端点查看:
curl -s 'https://127.0.0.1:6060/debug/pprof/goroutineleak?debug=2'
也可以用go tool pprof拉取压缩格式:
go tool pprof 'https://127.0.0.1:6060/debug/pprof/goroutineleak'
它的核心思路和普通goroutine profile不同。普通goroutine profile更像“快照”:当前有哪些goroutine,各自栈在哪里。goroutineleak profile更像一次带判断的诊断:runtime会借助GC的可达性分析,找出阻塞在channel、mutex、cond等并发原语上,而且已经没有可运行goroutine能再触达并唤醒它们的goroutine。
换句话说,它关心的不是“阻塞”,而是“不可恢复的阻塞”。这一点很关键。Go服务里有大量正常阻塞:HTTP server等连接、worker等任务、timer等时间、consumer等消息。它们不该被当成泄漏。真正危险的是那些被请求生命周期遗弃的goroutine:它们还占着栈和对象引用,但从业务逻辑上已经没有未来。
goroutineleak profile的启用方式也体现了它的定位:它是按需触发的诊断能力,不是每时每刻都在后台扫你的程序。对团队来说,这意味着可以先把它放进有代表性的测试和预发布环境,再考虑是否用于生产诊断。
AI Agent服务里的典型泄漏
来看一个很常见的工具并发调用场景。
type Tool interface {
Call(context.Context) (Result, error)
}
type toolResult struct {
result Result
err error
}
func callTools(ctx context.Context, tools []Tool) ([]Result, error) {
ch := make(chan toolResult)
for _, tool := range tools {
tool := tool
go func() {
result, err := tool.Call(ctx)
ch <- toolResult{result: result, err: err}
}()
}
results := make([]Result, 0, len(tools))
for range tools {
item := <-ch
if item.err != nil {
return nil, item.err
}
results = append(results, item.result)
}
return results, nil
}
这段代码在全部成功时没什么问题,但只要一个工具先返回错误,函数就会提前退出。其他goroutine如果随后执行到ch <- ...,会卡在发送上,因为已经没有接收方了。
在AI Agent服务里,这种模式非常容易出现:模型输出中途取消,工具调用还没返回;多个检索源并发查询,一个源报错后主流程提前失败;流式响应已经断开,后台token统计或审计任务还在写结果;上游超时后handler返回,子goroutine没有收到取消信号。
这些goroutine不一定会立刻制造明显故障。它们可能只是在后台堆积,让后续GC、内存占用和调度状态变得越来越难看。
更稳妥的写法,是让取消、发送和收尾形成闭环:
func callTools(ctx context.Context, tools []Tool) ([]Result, error) {
ctx, cancel := context.WithCancel(ctx)
defer cancel()
ch := make(chan toolResult, len(tools))
var wg sync.WaitGroup
for _, tool := range tools {
tool := tool
wg.Add(1)
go func() {
defer wg.Done()
result, err := tool.Call(ctx)
select {
case ch <- toolResult{result: result, err: err}:
case <-ctx.Done():
}
}()
}
go func() {
wg.Wait()
close(ch)
}()
results := make([]Result, 0, len(tools))
for item := range ch {
if item.err != nil {
cancel()
return nil, item.err
}
results = append(results, item.result)
}
return results, nil
}
这里有几个细节值得保留。第一,子goroutine共享派生出来的ctx。主流程失败时调用cancel(),子任务有机会尽快停止。第二,结果channel使用len(tools)作为缓冲。即使主流程已经决定返回,已经完成的goroutine也不会因为发送结果而卡死。第三,发送结果时使用select同时观察ctx.Done()。如果取消已经发生,goroutine可以直接退出。第四,用WaitGroup关闭channel,让接收循环有明确结束条件。
goroutineleak profile的意义就在这里:它可以帮助你验证这类改造是否真的让goroutine退出,而不是只让代码看起来更完整。
怎么把它放进测试和CI
对团队来说,最适合先接入的地方不是全量生产,而是那些容易出现取消、超时和提前返回的集成测试。
例如,一个Agent流式接口测试可以专门覆盖“客户端中途断开”的路径:
GOEXPERIMENT=goroutineleakprofile \
go test ./internal/agent -run TestStreamCancelDoesNotLeak -count=1
测试里可以在场景结束后主动读取profile:
func assertNoGoroutineLeak(t *testing.T) {
t.Helper()
profile := pprof.Lookup("goroutineleak")
if profile == nil {
t.Skip("requires GOEXPERIMENT=goroutineleakprofile")
}
var buf bytes.Buffer
if err := profile.WriteTo(&buf, 2); err != nil {
t.Fatalf("write goroutineleak profile: %v", err)
}
text := buf.String()
if strings.Contains(text, "created by") {
t.Fatalf("possible goroutine leak:\n%s", text)
}
}
这段检查可以根据项目实际输出再调细。更重要的是,它把“是否泄漏”从人工阅读日志,前移到了自动化验证。
可以优先把它加到这些测试里:请求取消后,模型流式读取goroutine是否退出;工具调用fan-out中,任意一个工具失败后其他工具是否退出;WebSocket、SSE、长轮询断开后,后台发送循环是否退出;RAG检索超时后,重排、摘要和统计goroutine是否退出;worker pool停止后,任务生产方和消费方是否都能收敛。
CI里不一定要对所有包都打开实验开关。更现实的做法,是先给并发边界明显的包加一个单独任务:
GOEXPERIMENT=goroutineleakprofile \
go test ./internal/agent ./internal/gateway ./internal/tools -count=1
等这条线稳定后,再把它纳入升级Go版本时的回归检查。
在线上怎么用
如果服务已经暴露内部pprof,可以在预发布或受控生产实例上打开实验构建,然后对疑似泄漏场景抓取profile。
一个比较克制的流程是:
GOEXPERIMENT=goroutineleakprofile go build -o bin/agent ./cmd/agent
压测或复现场景后抓取:
curl -o goroutineleak.pb.gz \
'https://127.0.0.1:6060/debug/pprof/goroutineleak'
go tool pprof -top goroutineleak.pb.gz
需要直接看栈时:
curl -s \
'https://127.0.0.1:6060/debug/pprof/goroutineleak?debug=2'
这里要注意权限边界。pprof端点不应该暴露到公网。goroutineleak profile里可能包含业务函数名、路径、参数处理栈和内部包结构,适合放在内网诊断、临时端口转发或受控调试通道里使用。
也不要把它当成每次请求都要采集的指标。它的定位是诊断和验证,不是高频监控。平时可以继续用goroutine数量、内存、GC、延迟和错误率观察趋势;一旦趋势异常,再用goroutineleak profile判断是不是存在不可恢复的阻塞。
它不能替你解决什么
goroutineleak profile很有用,但它不是并发设计的替代品。
它依赖可达性判断,因此有些泄漏并不会被识别出来。比如,一个全局worker pool还持有某个channel,某个goroutine虽然从业务上已经没有意义,但它等待的对象仍然可以从可运行goroutine触达,这种情况未必会被归类为泄漏。
它也不负责判断你的业务语义。一个goroutine是否“应该继续等待”,很多时候只有项目自己知道。比如后台订阅循环、长期缓存刷新、连接保持、队列消费,可能都是正常存在的goroutine。
所以团队落地时,仍然要保留几条基本纪律。第一,所有跟请求生命周期相关的goroutine,都应该能观察同一个context.Context。第二,fan-out后必须有收口策略:要么drain完结果,要么取消子任务,要么给发送侧足够的缓冲和退出路径。第三,channel的拥有者要清楚。谁创建,谁关闭,谁负责保证发送方不会被遗弃。第四,测试不要只覆盖成功路径。取消、超时、半路报错、客户端断开,才是goroutine泄漏最容易出现的地方。第五,把profile当成证据,而不是当成设计。它能帮你发现已经坏掉的路径,但更好的工程实践是让这些路径一开始就很难写错。
Go开发者现在该怎么做
如果你的项目正在升级Go 1.26,建议把goroutineleak profile加进升级清单里,哪怕暂时只在局部测试中使用。
第一步,挑出最容易泄漏的包:Agent编排、流式网关、工具调用、消息消费、定时任务、并发检索。第二步,为这些包补取消和提前返回测试。不要只测“所有工具都成功”,要测“第一个工具失败后其他工具会不会退出”。第三步,用实验开关跑这些测试,并在失败时保存profile输出,方便code review直接看到栈。第四步,针对发现的问题收敛出本项目的并发模板。比如统一使用errgroup.WithContext,或者统一使用“缓冲结果channel + cancel + WaitGroup”的写法。第五步,升级后观察线上goroutine、GC和内存曲线。如果某条服务在Agent请求量上升后goroutine数量持续走高,再用goroutineleak profile做定向诊断。
这次变化给Go开发者的信号很明确:goroutine泄漏正在从“靠经验排查的并发疑难杂症”,变成runtime可以参与诊断的工程问题。
对AI Agent服务来说,这尤其重要。模型调用会慢,工具调用会失败,客户端会断开,上游会超时,这些都不是异常事件,而是日常路径。真正可靠的系统,不能只在happy path上优雅;它还要能在每一次提前退出后,把后台goroutine干净地带回家。
Go 1.26的goroutineleak profile给了我们一个更好的检查点。用它去压取消路径、压失败路径、压流式断开路径,很多隐藏在后台的并发问题,会比以前更早浮出水面。
