Go 的 context.WithCancel 与 goroutine 泄漏排查要点

线上 Go 服务跑着跑着 RSS 从 80MB 涨到 900MB,GOMAXPROCS 没变、QPS 没涨,runtime.NumGoroutine() 却在单调上升——这类问题十有八九和 context.WithCancel 用得不对有关。本文从一次真实的内存增长切入,讲清 WithCancel 的传播语义、四种最常见的泄漏写法、如何用 pprof 和 g

线上 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 只是把”取消”这一层做对了,每个任务内部的取消响应仍要自己写。