Go:context —— goroutine 泄漏是怎么发生的
上两章讲的都是 Go 的漂亮之处。这一章讲代价。go f() 是一个单向的门:控制流跳出去,再也不回来。没有人返回,没有人等待,没有人负责取消。Go 把「谁来管这个 goroutine 的一生」这个问题完全甩给了程序员 —— 而这就是为什么 goroutine 泄漏是 Go 最常见的生产事故,而且编译器一个警告都不会给你。context 是 Go 对这个问题的答案,它是个好答案 —— 但它是一个纯靠纪律维系的答案。
先看一个 bug —— 它长得非常正常
// 一个「先返回最快的那个」的经典实现
func fastest(urls []string) string {
ch := make(chan string) // ← 无缓冲
for _, url := range urls { // 假设有 3 个 url
go func() {
ch <- fetch(url) // 各自去抓,谁快谁先写进 ch
}()
}
return <-ch // 拿到第一个就返回!
}
看起来很聪明,也确实能工作:它返回最快的那个结果。
但它每调用一次,就泄漏两个 goroutine。永久地。
ch 里读了。goroutine 2 和 3 会永远阻塞在
ch <- ... 那一行。它们的栈、它们持有的所有变量(那个 HTTP 响应体!)—— 永远不会被 GC 回收。这个函数每被调用一次,泄漏两个 goroutine。一个 QPS 1000 的接口,一小时泄漏 720 万个。然后 OOM。
两种修法
// ✅ 修法一:给 channel 加缓冲,让所有人都能发完就走
func fastest(urls []string) string {
ch := make(chan string, len(urls)) // ← 缓冲 = goroutine 数
for _, url := range urls {
go func() { ch <- fetch(url) }() // 现在谁都不会阻塞
}
return <-ch // 慢的那两个把结果写进缓冲,然后正常退出。
} // 结果没人读,但 goroutine 死了,内存会被回收。
// ✅ 修法二(更好):用 context 真的把它们取消掉
func fastest(ctx context.Context, urls []string) string {
ctx, cancel := context.WithCancel(ctx)
defer cancel() // ← 函数一返回,就取消所有子请求
ch := make(chan string, len(urls))
for _, url := range urls {
go func() {
ch <- fetchWithCtx(ctx, url) // ctx 取消 → 这个 HTTP 请求真的会被中断
}()
}
return <-ch
}
// 修法二不只是「不泄漏 goroutine」,它还【真的停掉了那两个网络请求】——
// 省下了带宽,也省下了下游服务的负载。
一个 goroutine 泄漏,意味着:
- 它的栈永远不会被释放(至少几 KB,如果它调用得深,可能几十 KB)
- 它引用的所有对象永远不会被 GC(那个
*http.Response、那个 buffer、那个 DB 连接……) - 它可能还持有着一把锁、一个数据库连接、一个文件句柄
Go 的 GC 救不了你。因为从 GC 的角度看,那个 goroutine 是活的(它只是在等一个永远不会到的事件)—— 它的栈是 GC root,它引用的一切都是可达的。
goroutine 泄漏 = 内存泄漏 + 资源泄漏,而且不可见。
五种泄漏姿势
| # | 姿势 | 怎么修 |
|---|---|---|
| 1 | 发送到没人接的 channelch <- v 永远阻塞(就是上面那个例子) |
加缓冲,或者用 select 带 ctx.Done():select {
case ch <- v:
case <-ctx.Done():
return // 没人要了,我走
} |
| 2 | 从没人发的 channel 接收for v := range ch,而没有人 close(ch) |
明确谁负责 close。发送方 goroutine 应该 defer close(ch)。 |
| 3 | 忘了 defer cancel()ctx, _ := context.WithTimeout(...)( go vet 能查出这个!) |
永远 defer cancel(),哪怕你觉得不需要 —— WithCancel / WithTimeout 会在父 ctx 上注册一个子节点,不 cancel 就不会摘除,父 ctx 活多久它就泄漏多久。 |
| 4 | 没停的 tickertime.NewTicker() 忘了 Stop()( time.Tick() 根本没法停!) |
defer ticker.Stop()。永远不要在生产代码里用 time.Tick() —— 它返回的 channel 无法被回收,官方文档明确说了。 |
| 5 | 无限循环的 worker 没有退出路径for { select { case job := <-jobs: ... } } |
每个长期存活的 goroutine 都必须有一个 <-ctx.Done() 分支。这是纪律。 |
context:Go 的取消机制
// context 的全部 API,就这四个方法:
type Context interface {
Done() <-chan struct{} // 一个 channel。取消时它会被【close】
Err() error // 为什么取消的:Canceled / DeadlineExceeded
Deadline() (time.Time, bool)
Value(key any) any // ← 这个方法是个后悔药,见下
}
// 关键洞察:取消信号就是【一个被 close 的 channel】。
// 因为 close 会让【所有】等待的接收方同时被唤醒 —— 这是天然的广播机制。
正确的用法
func handler(w http.ResponseWriter, r *http.Request) {
ctx := r.Context() // ← HTTP 框架已经给了你一个 ctx
// 客户端断开连接时,它会被自动取消!
ctx, cancel := context.WithTimeout(ctx, 3*time.Second)
defer cancel() // ← 【永远写这一行】
result, err := doWork(ctx) // ctx 一路传下去
...
}
func doWork(ctx context.Context) (Result, error) {
// ① 传给会阻塞的调用
req, _ := http.NewRequestWithContext(ctx, "GET", url, nil)
resp, err := http.DefaultClient.Do(req) // ctx 取消 → 这个请求真的会被中断
// ② 在长循环里检查
for _, item := range items {
select {
case <-ctx.Done():
return nil, ctx.Err() // 被取消了,赶紧走
default:
}
process(item)
}
// ③ 传给数据库
rows, err := db.QueryContext(ctx, "SELECT ...") // 用带 Context 的版本!
...
}
cancel() 做的事,只是 close(done)。它不能强行终止任何东西。
如果被调用的代码不检查 ctx.Done(),取消就完全无效。
func slowWork(ctx context.Context) {
time.Sleep(10 * time.Second) // ❌ 它不知道 ctx 是什么。
} // 取消?没门。这 10 秒必须走完。
// ✅
func slowWork(ctx context.Context) error {
select {
case <-time.After(10 * time.Second):
return nil
case <-ctx.Done():
return ctx.Err() // 可以被取消了
}
}
这和 Kotlin 的「协作式取消」是完全一样的道理(第 15 章)—— 只是 Kotlin 帮你把取消检查插进了每一个挂起点,而 Go 要你手写。
context.Value 是个后悔药,别当背包用// ❌ context 不是一个「万能背包」
ctx = context.WithValue(ctx, "userID", 42)
ctx = context.WithValue(ctx, "db", dbConn) // ❌❌❌
ctx = context.WithValue(ctx, "config", cfg) // ❌❌❌
func doWork(ctx context.Context) {
db := ctx.Value("db").(*sql.DB) // 运行时类型断言 —— 编译器帮不了你
// 这个函数的【真实依赖】,在签名上一个字都看不出来。
}
问题:
- 类型不安全(
any+ 断言) - 依赖隐形 —— 函数签名骗人
- 没有编译期检查 —— 忘了塞进去,运行时才 panic
- 查找是 O(n)(context 是个链表)
Go 官方的指导:context.Value 只应该用于「请求域的、跨 API 边界的、非业务的元数据」 —— trace ID、request ID、认证 token、语言偏好。就这些。
业务参数用参数传。依赖用结构体字段注入。(这就是 Kotlin 2.4 的 context parameters 想做对的事 —— 类型安全的隐式依赖。)
errgroup:Go 最接近「结构化并发」的东西
import "golang.org/x/sync/errgroup"
func fetchAll(ctx context.Context, urls []string) ([]Result, error) {
g, ctx := errgroup.WithContext(ctx) // ← 注意:它返回一个【新的 ctx】
g.SetLimit(10) // 限流:最多 10 个并发
results := make([]Result, len(urls))
for i, url := range urls { // Go 1.22+:循环变量按轮独立,不用再 i := i
g.Go(func() error {
r, err := fetch(ctx, url)
if err != nil {
return err // ← 【任何一个返回 error】
} // → ctx 被取消 → 其他所有 fetch 立刻中断
results[i] = r // 各写各的下标 —— 不需要锁
return nil
})
}
if err := g.Wait(); err != nil { // 等所有 goroutine 结束
return nil, err // 返回【第一个】错误
}
return results, nil
}
- 等待:
g.Wait()保证所有 goroutine 都结束了。没有孤儿。 - 错误传播:第一个错误会被返回。
- 取消传播:一个失败 →
ctx被取消 → 其他所有人立刻中断。
这三样,正好就是 Kotlin coroutineScope 的三个承诺(第 15 章)。
但有一个决定性的区别:
- Kotlin:这是语言的行为。你无法绕过它。只要你在一个 scope 里,结构化并发就是强制的。
- Go:这是一个第三方库(
golang.org/x/sync)。你完全可以在g.Go()旁边写一个裸的go f()—— 编译器不会说一个字。
这就是「纪律」和「保证」的区别。而在一个五十人的团队里、在一个跑了五年的代码库里 —— 纪律是会漏的。
(顺带:Go 1.25 给 sync.WaitGroup 加了一个 Go() 方法,wg.Go(func(){...}),省掉了 Add/Done 的样板。这是个好改进,但它仍然不管取消和错误 —— errgroup 还是更好的选择。)
怎么抓住泄漏
① 生产环境:pprof
import _ "net/http/pprof" // 就这一行,它会自动注册 handler
func main() {
go func() {
log.Println(http.ListenAndServe("localhost:6060", nil))
}()
// ... 你的服务
}
# 看当前有多少个 goroutine $ curl -s localhost:6060/debug/pprof/goroutine?debug=1 | head -1 goroutine profile: total 10523 # ← 10523 个?!你只有 8 个核。 # 看它们卡在哪(按栈聚合,这是最有用的视图) $ curl -s "localhost:6060/debug/pprof/goroutine?debug=1" goroutine profile: total 10523 10000 @ 0x43c5f6 0x40c8fb 0x40c8d5 0x7a3d4c 0x46d001 # ^^^^^^ 一万个 goroutine 卡在【同一个地方】—— 找到凶手了 # main.fetchAll.func1(...) # /app/fetch.go:42 +0x8c ← 就是这一行 # created by main.fetchAll # /app/fetch.go:40 +0x105 # 【关键诊断】:如果一个栈上聚集了成千上万个 goroutine,那就是泄漏点。
② 测试环境:goleak
import "go.uber.org/goleak"
func TestMain(m *testing.M) {
goleak.VerifyTestMain(m) // ← 就这一行
}
// 每个测试跑完后,它会检查有没有 goroutine 还活着。
// 有 → 测试失败,并打印出它的栈。
// 单个测试:
func TestFetch(t *testing.T) {
defer goleak.VerifyNone(t)
...
}
它把「goroutine 泄漏」从「生产环境的 OOM」变成了「CI 里的一个红叉」。
投入:一行代码。产出:一整类生产事故的消失。
这是本书里投入产出比最高的建议之一(另一个是 go test -race)。
③ 监控:把 goroutine 数量画成曲线
// 暴露成 Prometheus 指标 runtime.NumGoroutine() // 你要看的是【趋势】,不是绝对值: // · 平稳波动 → 健康 // · 单调上升,永不回落 → 【泄漏】。而且这条曲线会一路涨到 OOM。 // // 这条曲线是 Go 服务最重要的监控指标之一,甚至比 CPU 和内存更早报警。
| 启动一个并发任务 | 谁保证它会结束 | 忘了会怎样 | |
|---|---|---|---|
| Kotlin | launch { } / async { } |
语言(Job 树 + coroutineScope) | 忘不了。scope 不退出,你就出不去。 (除非你手动搞一个 GlobalScope.launch —— 那是 Kotlin 版的 go f(),而且 IDE 会警告你。) |
| Python 3.11+ | tg.create_task() |
TaskGroup(如果你用了它) | 用裸 create_task 就会泄漏。但至少标准库给了正确的那条路。 |
| Go | go f() |
你自己 | 静静地泄漏。没有编译器警告,没有运行时报错,没有任何提示。 直到 OOM。 |
这是 Go 为「无染色」付出的代价。它把语法上的复杂度拿掉了,但那个复杂度没有消失 —— 它变成了你必须遵守的纪律。
而下一章、下下章会反复回到这个主题。
1. 在你的 Go 项目里加上 goleak(一行)。然后跑一遍测试。我打赌你会找到至少一个泄漏。
2. 把 runtime.NumGoroutine() 加进你的监控。看它的曲线。如果它在单调上升 —— 恭喜,你发现了一个正在酝酿的生产事故。
3. 全局搜 go func()。对每一个问三个问题:谁等它?谁取消它?它 panic 了会怎样?(提示:一个裸 goroutine 里的 panic 会让整个进程崩溃 —— 因为没有人能 recover 它。)
4. 搜 context.WithTimeout / WithCancel,看有没有漏掉 defer cancel()。(go vet 会帮你查这个,把它加进 CI。)
下一章:三门语言都讲完了。现在给它们出同一道题,逐行对照。你会发现差异不在语法上 —— 在「谁替你管生命周期」这件事上。