线上 Go 服务跑着跑着 RSS 从 80MB 涨到 900MB,GOMAXPROCS 没变、QPS 没涨,runtime.NumGoroutine() 却在单调上升——这类问题十有八九和 context.WithCancel 用得不对有关。本文从一次真实的内存增长切入,讲清 WithCancel 的传播语义、四种最常见的泄漏写法、如何用 pprof 和 goleak 定位到具体代码行,以及生产环境下的防御措施。目标是把”知道要用 context”变成”能一眼看出哪一行会漏”。
一、先复现一个真实的 goroutine 泄漏
问题代码长这样,表面上非常正常:一个处理请求的 handler,起一个后台 goroutine 做异步日志上报,主流程处理完就返回。
package main
import (
"context"
"fmt"
"net/http"
"runtime"
"time"
)
func reportAsync(ctx context.Context, msg string) {
go func() {
// 模拟耗时上报
time.Sleep(2 * time.Second)
fmt.Println("report:", msg)
}()
}
func handler(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithCancel(r.Context())
defer cancel() // 看着没问题?
reportAsync(ctx, "user login")
w.Write([]byte("ok"))
}
func main() {
go func() {
for range time.Tick(2 * time.Second) {
fmt.Println("goroutines:", runtime.NumGoroutine())
}
}()
http.ListenAndServe(":8080", http.HandlerFunc(handler))
}
用 hey -z 30s -c 20 http://localhost:8080/ 压 30 秒,日志里的 goroutine 数会从 4 一路涨到几百,压测结束后也不会回落到初始值。原因很直接:reportAsync 里起的匿名 goroutine 完全没有监听 ctx.Done(),defer cancel() 只是取消了 context,对那个已经跑出去的 goroutine 毫无影响。它依然会 Sleep 满 2 秒才退出。
这个例子里泄漏是”有界的”——每个 goroutine 只活 2 秒。真正的线上事故通常是无界泄漏,典型特征是 goroutine 里有 for { select { ... } } 循环、ch <- v 阻塞写、或者 wg.Wait() 永远等不到。下面逐个拆。
二、context.WithCancel 到底传播了什么
很多人以为 cancel() 能”杀掉”子 goroutine,这是最大的误解。context 只是一个信号通道,它不会、也无法强制终止任何 goroutine。Go 没有 goroutine 抢占式 kill,唯一能让 goroutine 退出的方式是它自己走到 return。
WithCancel(parent) 做三件事:
- 创建一个
cancelCtx,内部持有done chan struct{}。 - 把自己注册到 parent 的 children 集合里;parent 被取消时,递归取消所有子孙。
- 返回的
cancel函数关闭done通道,并向 parent 注销自己,避免子节点在 parent 上无限堆积。
关键点在于第三条。看 cancelCtx.cancel 的实现(Go 1.21+ 在 context/context.go):
func (c *cancelCtx) cancel(removeFromParent bool, err, cause error) {
if err == nil {
panic("context: internal error: missing cancel error")
}
c.mu.Lock()
if c.err != nil {
c.mu.Unlock()
return // already canceled
}
c.err = err
c.cause = cause
d, _ := c.done.Load().(chan struct{})
if d == nil {
c.done.Store(closedchan)
} else {
close(d)
}
for child := range c.children {
child.cancel(false, err, cause)
}
c.children = nil
c.mu.Unlock()
if removeFromParent {
removeChild(c.Context, c)
}
}
注意最后两行:只有主动调用返回的 cancel 函数时,removeFromParent 才为 true。如果只依赖 parent 取消(比如 handler 返回后 r.Context() 被 net/http 取消),子 context 会从 children map 里被摘掉;但如果你起了子 context 却忘了 cancel,它就会一直挂在 parent 的 children map 上,直到 parent 被取消。对 context.Background() 派生的 context 来说,parent 永远不会取消,这个 map 只增不减——这就是”context 泄漏”,虽然不直接等于 goroutine 泄漏,但会一起出现。
实践规则:
context.WithCancel、WithTimeout、WithDeadline返回的cancel函数必须被调用,哪怕你确定它已经超时了。静态检查工具go vet的lostcancel检查能抓出一部分漏掉的写法。
三、四种高频泄漏写法及修正
3.1 起了 goroutine 却不读 Done
最常见的一种,就是第一节的例子。修正的核心是让 goroutine 真正响应取消信号:
func reportAsync(ctx context.Context, msg string) {
go func() {
select {
case <-time.After(2 * time.Second):
fmt.Println("report:", msg)
case <-ctx.Done():
// 上游已取消,放弃上报
return
}
}()
}
但这样写仍然有个隐患:time.After 在 select 提前走 ctx.Done() 分支时,那个 timer 不会被回收,直到 2 秒后才被 GC。高频调用下要看 runtime.MemStats.Mallocs。更稳妥的做法是用 time.NewTimer 加 defer timer.Stop():
func reportAsync(ctx context.Context, msg string) {
go func() {
t := time.NewTimer(2 * time.Second)
defer t.Stop()
select {
case <-t.C:
fmt.Println("report:", msg)
case <-ctx.Done():
return
}
}()
}
3.2 阻塞写无人接收的 channel
这类泄漏比第一种隐蔽得多,因为代码里明明有 select:
func produce(ctx context.Context, out chan<- int) {
go func() {
for i := 0; ; i++ {
select {
case <-ctx.Done():
return
default:
}
out <- i // 下游走了,这里永久阻塞
}
}()
}
out <- i 这一行不在 select 里。一旦调用方不再从 out 读取(比如请求被取消、客户端断开),发送方就永远卡在这里,ctx.Done() 再也不会被检查到。
func produce(ctx context.Context, out chan<- int) {
go func() {
for i := 0; ; i++ {
select {
case <-ctx.Done():
return
case out <- i:
}
}
}()
}
两个分支都放进 select,取消信号才有机会被调度。这个模式的变体是向无缓冲 channel 发送结果、或用 sync.WaitGroup 等待永远不 return 的 worker,根因都一样:阻塞点必须位于 select 的可选中。
3.3 循环条件写成 for range 无退出
// 错误:range 本身没有 ctx 退出路径
go func() {
for v := range in {
process(v)
}
}()
// 正确
go func() {
for {
select {
case <-ctx.Done():
return
case v, ok := <-in:
if !ok {
return
}
process(v)
}
}
}()
for v := range in 只在 in 被 close 时退出。如果生产者因为客户端断开而提前 return,忘了 close(in),消费者就永久挂着。凡是交给 goroutine 消费的 channel,都要明确”谁负责 close”。
3.4 忘记调用 cancel,且 parent 是 Background
func startWorker() {
ctx, _ := context.WithCancel(context.Background()) // 丢了 cancel
go func() {
<-ctx.Done() // 永远等不到
}()
}
// 正确
func startWorker() error {
ctx, cancel := context.WithCancel(context.Background())
defer cancel() // 或者把 cancel 存到 struct,由 Stop() 方法调用
go func() {
select {
case <-ctx.Done():
case <-time.After(30 * time.Second):
}
}()
return nil
}
如果这个 context 的生命周期本来就该跟进程一致(比如全局后台任务),那没问题;但如果你希望它跟着某个组件、某次请求结束,就必须把 cancel 暴露出去。常见设计是给它一个 Close() 或 Stop() 方法。
四、用 pprof 定位泄漏点
只靠读代码找泄漏成本很高,工具链能直接把 goroutine 栈打出来。标准做法是在服务里挂 net/http/pprof:
import (
"net/http"
_ "net/http/pprof"
)
func main() {
go func() {
// 生产环境记得单独监听内部端口,不要暴露在公网
http.ListenAndServe("127.0.0.1:6060", nil)
}()
// ... 业务启动
}
出问题时抓两次快照做 diff,比单次快照有用得多——单次快照里大部分 goroutine 是正常的,只有对比才能看出”哪些栈在变多”:
$ curl -s 'http://127.0.0.1:6060/debug/pprof/goroutine?debug=1' > g1.txt
# 等待 30 秒,让流量跑一轮
$ curl -s 'http://127.0.0.1:6060/debug/pprof/goroutine?debug=1' > g2.txt
$ diff g1.txt g2.txt | head -60
debug=1 输出的是按栈聚合的可读文本,第一行是 goroutine 数量汇总,形如:
goroutine profile: total 1873
1320 @ 0x43b6e5 0x43b8a9 0x46c6e7 0x4a91b5 0x4d2bad 0x4d2e05 0x4d3f85 0x5c1e6f
# 0x43b6e4 runtime.gopark+0x64 /usr/local/go/src/runtime/proc.go:424
# 0x43b8a8 runtime.chanrecv+0xe8 /usr/local/go/src/runtime/chan.go:639
# 0x46c6e6 internal/poll.runtime_pollWait+0x86 /usr/local/go/src/runtime/netpoll.go:351
# 0x4a91b4 internal/poll.(*pollDesc).wait+0x94 /usr/local/go/src/internal/poll/fd_poll_runtime.go:84
# 0x4d2bac net.(*conn).Read+0x8c /usr/local/go/src/net/net.go:196
# 0x4d2e04 net/http.(*conn).serve+0x464 /usr/local/go/src/net/http/server.go:2087
# 0x4d3f84 main.produce+0x104 /app/main.go:47
# 0x5c1e6e created by main.handler in goroutine 6312 /app/main.go:53
看到 created by ... in goroutine N 这行,泄漏源头基本就锁定了。0x4d3f84 main.produce+0x104 /app/main.go:47 直接指向阻塞在 out <- i 的那一行。
如果泄漏的 goroutine 数量太多、IP 上不方便开端口,可以用 runtime/pprof 在代码里落盘:
func dumpGoroutines(path string) error {
f, err := os.Create(path)
if err != nil {
return err
}
defer f.Close()
p := pprof.Lookup("goroutine")
return p.WriteTo(f, 1)
}
配合定时器或信号(SIGQUIT 默认就会打印所有 goroutine 栈并退出,适合紧急抓现场)。
五、把泄漏挡在测试阶段:goleak 与代码规范
线上排查是被动的,更划算的是让 CI 拦住它。go.uber.org/goleak 可以在测试结束时检查有没有残留 goroutine:
package main
import (
"context"
"testing"
"time"
"go.uber.org/goleak"
)
func TestProduceStops(t *testing.T) {
defer goleak.VerifyNone(t)
ctx, cancel := context.WithCancel(context.Background())
out := make(chan int)
produce(ctx, out)
// 故意不读 out,只取消 ctx,验证 goroutine 能退出
cancel()
// 给 goroutine 一点时间响应取消
time.Sleep(50 * time.Millisecond)
}
如果 produce 写成了 3.2 节的错误版本,测试会失败并输出:
--- FAIL: TestProduceStops (0.05s)
leaks.go:78: found unexpected goroutines:
[Goroutine 21 in state chan send, with main.produce.func1 on top of the stack:
goroutine 21 [chan send]:
main.produce.func1()
/app/main.go:47 +0x45
created by main.produce in goroutine 20
/app/main.go:43 +0x95
]
注意 goleak.VerifyNone 默认会忽略一批标准库长期存活的 goroutine(如 runtime.gopark 中的 net/http 相关工作),但自己起的业务 goroutine 一个都跑不掉。如果确实有需要容忍的,用 goleak.IgnoreTopFunction("xxx") 精确排除,不要整体关掉检查。
规范层面,落几条能写进 review checklist 的约定:
- 函数签名里接收
ctx context.Context的,一律作为第一个参数,且不存进 struct 字段(少数有明确生命周期的组件除外)。 - 起 goroutine 的地方必须能回答两个问题:靠什么退出?谁负责等它退出?回答不了就别起。
go func()内部第一件事就是select { case <-ctx.Done(): return ... },把它当成模板。- 对外提供的组件,如果内部有生命周期,暴露
Close()/Stop()并幂等(用sync.Once保护 cancel)。 - CI 里跑
go vet ./...和带-race的测试,lostcancel能抓到不少低级错误。
六、小结
context.WithCancel 本身不会泄漏,泄漏的是”起了 goroutine 却没给它退出路径”的代码。判断标准很简单:任何一处阻塞(channel 收发、time.Sleep、锁等待、网络 IO)都必须能被 ctx.Done() 打断,否则取消信号形同虚设。cancel 函数必须被调用,否则会在 parent 的 children map 上留下痕迹。定位靠 pprof 的 goroutine 快照做 diff,防御靠 goleak 和 go vet。把这三件事固定下来,goroutine 泄漏基本就退化成”写错就编译/测试失败”的普通 bug 了。
FAQ
Q1:调用了 cancel(),goroutine 还是没退出,为什么?
先确认这个 goroutine 是否读取了 ctx.Done(),以及读取的位置是否在 select 的可选分支里。如果它阻塞在 ch <- v、<-ch(非 select 写法)、sync.Mutex.Lock() 或者 time.Sleep 上,取消信号根本传不进去。可以临时用 pprof 看它的栈顶,停在 chan send 或 chan receive 就说明是这个问题。
Q2:context.WithTimeout 超时后还需要调用 cancel 吗?
需要。超时只会关闭内部的 done channel,但不会把自己从 parent 的 children 里摘除;只有调用返回的 cancel 才会执行 removeChild。所以标准写法永远是 defer cancel(),即使设置了 timeout。
Q3:为什么 goroutine 数会周期性上涨但压测结束后能掉回去?
这种通常不是泄漏,而是 goroutine 有固定的最大存活时间(比如 time.Sleep(2*time.Second) 后才退出),属于”延迟退出”。判断标准是:停止流量后等足够长时间,看 runtime.NumGoroutine() 是否回到基线。回不去才是泄漏。建议在监控里同时打 goroutine 数和请求数,比值持续上升才是强信号。
Q4:goleak 报出的 goroutine 里包含标准库的,怎么处理?
goleak 内置了常见标准库的忽略列表,但仍可能随 Go 版本更新出现新的长期存活 goroutine。用 goleak.IgnoreTopFunction("net/http.(*Server).Serve") 之类精确忽略,并且只在测试函数级别忽略,不要写进 TestMain 全局关闭——那会让整个包的检查失效。
Q5:用 errgroup.WithContext 能避免泄漏吗?
errgroup 解决的是”任一 goroutine 出错就取消全部同类任务”的协调问题,它确实在内部正确调用了 cancel,但它不能强迫你的 goroutine 退出。如果你的任务体不读 ctx.Done(),eg.Wait() 就会永远等下去。所以 errgroup 只是把”取消”这一层做对了,每个任务内部的取消响应仍要自己写。