我的 HTTP 请求明明已经超时返回了,可它在后台启动的 goroutine 还在埋头跑、下游调用也没停,goroutine 越积越多内存一路涨,因为我没把 context 的取消信号传下去、也没人监听它:一次取消信号没传播没人接、goroutine 泄漏的深度复盘
那个"内存缓慢上涨、goroutine 数量只增不减"的服务,问题出在我对 Go 的 context 取消机制只用了一半。我有个接口,处理请求时会启动一些 goroutine 做并行子任务(查下游、做计算),并给请求设了超时。线上现象诡异:有些请求超时返回了(给用户报了超时错误),可监控显示 goroutine 数量只增不减、内存一路缓慢上涨。排查发现:那些超时的请求,主流程是返回了,但它启动的 goroutine 还在后台埋头跑(还在查那个早已没人要结果的下游、还在算那个早已没人等的结果),跑完也没人收、白白耗着资源,然后悄悄泄漏——积少成多,goroutine 越来越多。复盘这件事,我才彻底搞懂,后背发凉:问题出在我给请求设了超时(创建了带取消的 context),却没把这个"取消信号"传播下去、也没让 goroutine 监听它。Go 用 context 来传播"取消/超时"信号:当请求超时或被取消,对应的 ctx 会被 cancel,ctx.Done() 这个 channel 会关闭——这是在通知"别干了,上游已经不要结果了";但这个通知不会自动作用到任何 goroutine——必须做两件事它才生效:① 把 ctx 一路传递给所有该被取消的 goroutine 和下游调用;② 每个 goroutine 在阻塞/循环处主动监听 ctx.Done(),收到取消就停下、返回;我两件都没做:启动 goroutine 时没把 ctx 传进去(或传了但 goroutine 没监听 ctx.Done());于是请求超时、ctx 被 cancel 了,可那些 goroutine 根本不知道——它们没收到、也没在听这个"停下来"的信号,继续埋头跑到天荒地老,然后泄漏。根本原因是:context 的取消/超时信号不会自动作用到 goroutine,必须把 ctx 一路传播下去、且每个 goroutine 主动监听 ctx.Done() 才生效;我没传播也没监听,请求超时后 goroutine 收不到取消信号、继续跑、泄漏。问题的根,是取消信号没传播也没人监听——ctx 取消了但没传给 goroutine、goroutine 也没听 ctx.Done(),导致请求结束后它们还在跑、泄漏;根源是以为设了超时就够、忽略了取消要主动传播和监听才生效。这篇就把这次"context 取消失效、goroutine 泄漏"的坑,从头到尾复盘一遍。
故障现场:请求超时了,goroutine 还在跑
问题在于取消信号没传给 goroutine、goroutine 也没监听:
// 我的写法: 设了超时, 但没把ctx传给goroutine、goroutine也没监听
func handler(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 2*time.Second) // 设2秒超时
defer cancel()
resultCh := make(chan Result)
go func() { // ✗ 启动goroutine, 但没传ctx、没监听ctx.Done()
result := slowQuery() // 慢查询(可能跑很久), 它不知道上游已超时
resultCh <- result // 若主流程已超时返回, 这里发送会永久阻塞 → goroutine泄漏!
}()
select {
case res := <-resultCh:
respond(w, res)
case <-ctx.Done(): // 超时: 主流程返回超时错误
http.Error(w, "timeout", 504) // 但上面那个goroutine还在跑、还想往resultCh发, 没人收 → 泄漏!
}
}
/*
泄漏是怎么发生的:
1. 请求超时, select走到 ctx.Done() 分支, 主流程返回504;
2. 但那个goroutine里的 slowQuery() 还在跑(它没监听ctx, 不知道要停);
3. slowQuery()终于跑完, 想往 resultCh 发 → 但主流程已经走了, 没人接收;
4. 无缓冲channel发送, 没接收方 → 这个goroutine【永久阻塞】在发送处 → 泄漏!
5. 每个超时的请求都泄漏一个这样的goroutine → 越积越多 → 内存涨、goroutine数只增不减。
context 取消机制怎么用(两件事缺一不可):
① 传播: 把 ctx 一路【传递】给所有该被取消的goroutine、函数、下游调用(ctx作第一个参数);
② 监听: 每个goroutine在阻塞/循环处, 用 select 监听 ctx.Done(), 或用ctx版本的API
(如 http.NewRequestWithContext、db.QueryContext), 收到取消就停下返回。
- context的取消信号(ctx.Done()关闭), 只是"广播了一个停止的通知";
- 这个通知【不会自动让任何goroutine停下】——goroutine必须主动"听"到它、并响应(停下);
- 没传ctx → goroutine收不到通知; 没监听ctx.Done() → 收到也不理 → 都会继续跑、泄漏。
★ 核心: context的取消/超时信号不会自动作用到goroutine; 必须①把ctx一路传播下去 ②每个goroutine
主动监听ctx.Done()并响应; 缺任一, 上游取消了下游还在跑, 导致goroutine泄漏、做无用功。
看着监控里那条只涨不跌的 goroutine 曲线,我又懊恼又恍然:"我以为给请求设了超时,超时了一切就都停了……谁知道'超时'只是主流程返回了,我后台那些 goroutine 压根不知道、也没在听'该停了'的信号,还在傻乎乎地跑、跑完没人收、就卡死在那泄漏了。取消这事儿,我设了个开关,却没把线接到该停的人那里。"这个坑最隐蔽的地方在于:它功能上看起来正常(超时确实返回了超时错误,用户体验没问题);泄漏是悄悄、缓慢累积的(每次超时漏一个 goroutine),要跑很久、量很大才表现为内存上涨、goroutine 暴增;而且 goroutine 泄漏不报错,只能靠监控 goroutine 数量、pprof 才能发现。下面就来拆解,context 取消该怎么正确用。
第一件事:搞懂 context 取消的传播与监听
我顺着这次事故,把 context 取消机制彻底理清了。
context 取消怎么生效? 怎么避免 goroutine 泄漏?
【核心: context的取消/超时信号不会自动作用到goroutine; 必须①把ctx一路传播给所有该取消的goroutine/下游
②每个goroutine主动监听ctx.Done()并停下; 缺任一上游取消下游还跑→泄漏; 取消要"传到+听到+响应"才生效】
1. context 是什么:
- Go用context.Context传播"取消、超时、截止时间、请求级数据";
- ctx被cancel(或超时/到截止)时, ctx.Done()这个channel会关闭 → 广播"该停了"的通知;
- ctx.Err()返回取消原因(Canceled/DeadlineExceeded)。
2. 取消信号生效的两个必要条件:
① 传播: ctx必须被一路传递给所有"该随它一起取消"的goroutine、函数、下游调用;
- 惯例: ctx作为函数第一个参数 func Foo(ctx context.Context, ...);
- 启动goroutine要把ctx传进去; 调下游用ctx版本的API。
② 监听+响应: 每个goroutine在阻塞点/循环里, 主动监听ctx.Done(), 收到就停下返回;
- select { case <-ctx.Done(): return ctx.Err(); case ... }
- 或用ctx版本的阻塞API(http.NewRequestWithContext / db.QueryContext / channel配select)。
3. 没做好会怎样(goroutine泄漏):
- 没传ctx → goroutine收不到取消通知, 一直跑;
- 传了但没监听ctx.Done() → 收到也不理, 一直跑;
- 往无接收方的channel发送/无限等 → 永久阻塞 → goroutine泄漏;
- 泄漏的goroutine占内存、占资源, 只增不减 → 内存涨、句柄涨。
4. 正确用法:
- ctx一路传递(第一个参数), 别丢; 下游调用、子goroutine都带上ctx;
- 阻塞操作都监听ctx.Done()(select)或用ctx版本API;
- WithTimeout/WithCancel记得defer cancel()(释放ctx资源, 否则也会泄漏);
- channel收发配合select+ctx.Done(), 避免无人收的永久阻塞;
- 用pprof/runtime.NumGoroutine()监控goroutine数, 揪泄漏。
5. 本质: "取消/放弃"的指令, 必须被主动传播到每个执行者、且每个执行者主动监听并响应, 才会真停下
- 上游"放弃了/超时了", 这个事实不会自动让正在为它干活的下游/goroutine知道并停下;
- 必须有一条"贯通的通知链"(传播) + "接收方主动听并停"(监听响应), 取消才真正生效;
- 否则: 上游早已放弃, 下游还在埋头苦干——做无用功、占着资源、泄漏。
一句话: context的取消信号不会自动停掉goroutine; 必须把ctx一路传播下去 + 每个goroutine主动监听ctx.Done()
并停下(用select或ctx版本API), 缺任一就泄漏; 取消要"传到+听到+响应"三者齐全才生效, 别忘defer cancel。
这套认知,是整个坑的根。context 是什么:Go 用它传播取消/超时;ctx 被 cancel/超时时 ctx.Done() 这个 channel 关闭、广播"该停了"。取消生效的两个必要条件:①传播(ctx 一路传给所有该取消的 goroutine/下游,作第一个参数);②监听+响应(每个 goroutine 监听 ctx.Done()、收到就停下返回,或用 ctx 版本 API)。没做好的后果:没传/没监听→goroutine 收不到或不理取消→继续跑→往无人收的 channel 发送永久阻塞→泄漏→内存涨。正确用法:ctx 一路传递、阻塞操作监听 ctx.Done() 或用 ctx 版本 API、defer cancel() 释放、channel 配 select+ctx.Done()、pprof 监控 goroutine 数。本质:"取消/放弃"的指令必须主动传播到每个执行者、且每个执行者主动监听并响应才真停下——上游放弃了,不告诉下游、下游不听,下游就还在埋头苦干、泄漏。一句话:context 的取消信号不会自动停掉 goroutine;必须把 ctx 一路传播下去 + 每个 goroutine 主动监听 ctx.Done() 并停下(用 select 或 ctx 版本 API),缺任一就泄漏;取消要"传到+听到+响应"三者齐全才生效,别忘 defer cancel。
第二件事:正解——ctx 一路传递,每个 goroutine 监听 ctx.Done()
知道了取消要传播+监听,正解就清楚了:ctx 一路传下去,阻塞点都监听它。
// 正解1: ctx 传给 goroutine, goroutine 监听 ctx.Done()(本次缺的)
func handler(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 2*time.Second)
defer cancel() // ✓ 别忘! 释放ctx, 否则定时器等资源泄漏
resultCh := make(chan Result, 1) // ✓ 带缓冲1: 即使主流程走了, goroutine发送也不阻塞(不泄漏)
go func() {
result := slowQueryCtx(ctx) // ✓ 把ctx传进慢查询, 让它能被取消
select {
case resultCh <- result: // 正常: 发送结果
case <-ctx.Done(): // ✓ 监听取消: 上游不要了, 我也别傻等发送, 直接退出
}
}()
select {
case res := <-resultCh:
respond(w, res)
case <-ctx.Done():
http.Error(w, "timeout", 504) // 超时返回; 此时goroutine也会因ctx.Done()而退出, 不泄漏
}
}
// 正解2: 下游调用、慢操作都用 ctx 版本的 API / 监听 ctx.Done()
func slowQueryCtx(ctx context.Context) Result {
// ✓ 用ctx版本的API: 上游取消时, 这些调用会自动中止
req, _ := http.NewRequestWithContext(ctx, "GET", url, nil) // HTTP带ctx
// rows, _ := db.QueryContext(ctx, sql, args...) // DB带ctx
// 循环里也监听: for { select { case <-ctx.Done(): return; default: ... } }
...
}
// 正解3: ctx 作为第一个参数, 一路往下传(惯例, 别丢)
func doWork(ctx context.Context, x int) error { // ✓ ctx是第一个参数
return callDownstream(ctx, x) // ✓ 继续往下传
}
// 正解4: WithCancel/WithTimeout 必须 defer cancel()
ctx, cancel := context.WithCancel(parent)
defer cancel() // ✓ 不调cancel会泄漏(ctx及其关联资源不被回收)
// 正解5: 监控goroutine数, 揪泄漏
// - runtime.NumGoroutine() 看数量趋势; net/http/pprof 的 /debug/pprof/goroutine 看堆栈;
// - 数量只增不减 → 有泄漏, 看pprof里大量卡在同一处的goroutine。
// 反例(别这样):
// go func(){ slowQuery(); ch<-r }() // ✗ 没传ctx、无缓冲ch、不监听 → 泄漏(本次)
// ctx,_ := context.WithTimeout(...) // ✗ 丢了cancel, 没defer cancel → 泄漏
// 核心: ctx作第一个参数一路传递、阻塞点都监听ctx.Done()(select)或用ctx版本API、channel带缓冲或配
// select、WithTimeout/Cancel记得defer cancel、用pprof监控goroutine数; 让取消信号"传到+听到+响应"。
这套正解的关键,是让"取消信号"真正传到每个 goroutine、且每个 goroutine 都在听并会响应。ctx 传给 goroutine + 监听 ctx.Done():把 ctx 传进去,goroutine 在发送/阻塞处用 select 监听 ctx.Done(),上游取消就退出——这正是本次缺的。下游用 ctx 版本 API:http.NewRequestWithContext、db.QueryContext,上游取消时自动中止。ctx 作第一个参数一路传:别中途丢了。WithTimeout/WithCancel 必 defer cancel():否则 ctx 关联资源泄漏。channel 带缓冲或配 select:避免无人收时永久阻塞;用 pprof 监控 goroutine 数揪泄漏。
第三件事:其他几个"信号没传到/没人响应"的坑
顺着这次 context 取消,我把"该传播的信号没传到、或没人响应"的几类坑也一并理了:
几类"信号没传播到/没人响应"的坑:
坑1: ctx取消没传播/没监听(本篇)——上游取消下游还跑、goroutine泄漏; 正解: 传ctx+监听Done。
坑2: 优雅停机信号没传到所有goroutine(同356)——收到SIGTERM主进程退了, 后台goroutine没收到、活儿没收尾;
正解: 用ctx/channel广播停机信号, 各goroutine监听并优雅退出。
坑3: 配置变更没通知到所有用到它的地方(同595)——改了配置, 缓存它的地方没收到变更通知;
正解: 监听/订阅变更, 主动刷新。
坑4: 事件发了没人订阅/订阅了不处理——发布了事件但没有消费者, 或消费者没正确处理;
正解: 确认有订阅者且会处理。
坑5: 错误/异常没向上传播(同605 finally吞、578吞异常)——下层出错没传给上层, 上层不知道;
正解: 错误要有归宿、能传到该处理它的地方。
坑6: 限流/熔断状态没共享给所有节点——单机限流/熔断, 其他节点不知道, 整体没生效;
正解: 状态用集中存储共享或广播。
共同的根: 分布式/并发系统里, 很多"控制信号"(取消、停机、变更、错误、状态)需要"从产生它的地方,
传播到所有该响应它的地方; 且每个接收方主动监听并响应"——这条"通知链"任何一环断了(没传到/没人听/
不响应), 信号就形同虚设, 导致"上游变了下游不知道"(继续跑无用功、泄漏、不一致)。要建好通知链。
这些坑看似不同,根却是同一个:分布式/并发系统里,很多"控制信号"(取消、停机、变更、错误、状态)需要"从产生它的地方,传播到所有该响应它的地方;且每个接收方主动监听并响应"——这条通知链任何一环断了(没传到/没人听/不响应),信号就形同虚设。认清这个根("控制信号要建好贯通的通知链:传到 + 听到 + 响应"),才不会让"上游变了下游浑然不知"。
第四件事:取消生效的条件 / context 用法要点——两张对照表
我把取消信号生效的条件、以及 context 用法要点,整理成对照表,贴在了团队的 Go 规范里:
| 环节 | 做了会怎样 | 没做会怎样 |
|---|---|---|
| ctx 一路传递 | 下游能收到取消 | 下游收不到,继续跑 |
| goroutine 监听 ctx.Done() | 收到取消就停 | 收到也不理,继续跑 |
| 下游用 ctx 版本 API | 取消时自动中止 | 慢调用不受取消影响 |
| channel 带缓冲/配 select | 无人收也不阻塞 | 无人收→永久阻塞→泄漏 |
| defer cancel() | 释放 ctx 资源 | ctx 关联资源泄漏 |
| 要点 | 怎么做 |
|---|---|
| ctx 位置 | 函数第一个参数,别存进结构体 |
| 阻塞操作 | 监听 ctx.Done() 或用 ctx 版本 API |
| 启动 goroutine | 传 ctx,并让它能被取消退出 |
| WithTimeout/WithCancel | 必 defer cancel() |
| 排查泄漏 | pprof goroutine / NumGoroutine 趋势 |
这两张表的核心,第一张是取消要生效,"传递"和"监听"缺一不可——任一环没做,goroutine 就收不到或不响应取消、继续跑或泄漏;第二张是ctx 作第一个参数一路传、阻塞点监听 Done、必 defer cancel、用 pprof 揪泄漏。记住一条:给操作设了 ctx,要确认它真的传到了每个 goroutine、且每个 goroutine 都在听并会停——否则超时只是"主流程返回了",后台还在跑。
第五件事:关于 context 取消的几组容易想当然的认知
这次事故也让我厘清了几组关于 context 取消的、容易想当然的概念:
| 直觉以为 | 实际上 |
|---|---|
| 设了超时,超时后一切就停了 | 只是主流程返回,goroutine 不会自动停 |
| cancel 会自动杀掉 goroutine | 不会,goroutine 要自己监听并退出 |
| 不传 ctx 也能取消下游 | 没传 ctx,下游收不到取消信号 |
| ctx.Done() 关闭会打断正在跑的代码 | 不打断,代码要主动 select 监听它 |
| goroutine 跑完就回收了 | 卡在无人收的 channel 会永久阻塞泄漏 |
| WithTimeout 不调 cancel 没关系 | 不调 cancel 会泄漏 ctx 关联资源 |
| goroutine 泄漏会报错 | 不报错,靠监控 goroutine 数/pprof 发现 |
这张表里,我栽的是第一行和第二行:以为"设了超时、超时后一切就停了"、"cancel 会自动杀掉 goroutine",没意识到取消只是发了个通知、goroutine 不主动听并退出就会继续跑、泄漏。厘清这些,核心是一个意识:Go 的 context 取消,是一种"协作式"的取消——它只广播一个"该停了"的通知,不会强制打断任何 goroutine;要让取消真正生效,必须把 ctx 一路传到每个 goroutine、且每个 goroutine 主动监听 ctx.Done() 并在收到时停下;"发了取消"和"真的停了"之间,隔着"传到 + 听到 + 响应"这条必须打通的链。
第六件事:用 goroutine / context 时,我现在的自检习惯
现在每当我启动 goroutine、或给操作设取消/超时,我都会先按这张图问自己:
这张图的精髓,是"goroutine 要能被取消:传 ctx + 监听 ctx.Done + 防 channel 阻塞 + defer cancel"。先问它会不会在请求后还跑(会就让它可取消)、ctx 传了吗、监听 Done 了吗、channel 会不会阻塞、有没有 defer cancel。这套习惯,让我从"设了超时就以为万事大吉"变成了"确认取消信号能传到、听到、响应"——核心始终是:context 的取消信号不会自动停掉 goroutine;必须把 ctx 一路传播下去 + 每个 goroutine 主动监听 ctx.Done() 并停下;取消要"传到+听到+响应"三者齐全才生效。
我立下的几条规矩
这场"请求超时了 goroutine 还在跑、泄漏"的事故,换来了我写 Go 并发时,刻进骨子里的几条铁律:
- context 的取消/超时信号不会自动停掉 goroutine——它只广播"该停了"的通知,是协作式取消。
- 取消要生效,必须:① ctx 一路传播给该取消的 goroutine/下游;② 每个 goroutine 主动监听 ctx.Done() 并停下。
- 启动的 goroutine 要能被取消退出;阻塞点用 select 监听 ctx.Done() 或用 ctx 版本 API。
- 下游调用用 ctx 版本(http.NewRequestWithContext / db.QueryContext),上游取消时自动中止。
- channel 收发配 select+ctx.Done() 或带缓冲,避免无人收时永久阻塞导致泄漏。
- WithTimeout/WithCancel 必须 defer cancel(),否则 ctx 关联资源泄漏。
- goroutine 泄漏不报错;用 runtime.NumGoroutine()/pprof 监控 goroutine 数趋势揪泄漏。
附:用 errgroup 优雅管理"一组带取消的并发 goroutine"
借这次的坑,我现在管理"一组并发 goroutine + 取消传播"时,优先用 golang.org/x/sync/errgroup——它把"传 ctx、任一失败就取消其他、等待全部"都封装好了,不容易漏。
import "golang.org/x/sync/errgroup"
func fetchAll(ctx context.Context, urls []string) ([]Result, error) {
// errgroup.WithContext: 派生一个ctx, 任一goroutine返回错误就自动cancel这个ctx
g, ctx := errgroup.WithContext(ctx)
results := make([]Result, len(urls))
for i, url := range urls {
i, url := i, url // 循环变量(Go1.22前需这行, 同544)
g.Go(func() error {
// ✓ ctx被传进来; 任一失败/上游取消, ctx.Done()关闭, 其他goroutine能感知
req, _ := http.NewRequestWithContext(ctx, "GET", url, nil) // 用ctx版本API
resp, err := http.DefaultClient.Do(req)
if err != nil {
return err // 返回错误 → errgroup自动cancel ctx → 其他goroutine也会被取消
}
results[i] = parse(resp)
return nil
})
}
if err := g.Wait(); err != nil { // 等全部完成; 返回第一个错误
return nil, err // 此时其余goroutine已因ctx取消而停止, 不泄漏
}
return results, nil
}
// errgroup 帮你做好了:
// - 统一管理一组goroutine、等待全部完成(g.Wait, 不用手写WaitGroup, 同592);
// - 任一goroutine出错, 自动取消派生的ctx → 其他goroutine能感知并停止(取消传播);
// - 收集第一个错误返回。
// 你只需: 用传入的ctx(它会被取消)、阻塞操作用ctx版本API/监听ctx.Done()。
// 原则: 对"一组带取消语义的并发任务", 用errgroup这类成熟封装, 把"取消传播+等待+错误收集"
// 这些容易漏、容易写错的协作细节交给它, 比每次手写ctx传递+WaitGroup+cancel更不易出错。
这个 errgroup 封装的价值,在于它把"取消传播"这件容易漏的事,变成了"用对工具就自动做好":你用它派生的 ctx、任一任务失败就自动取消其余、g.Wait() 等齐——"传到 + 听到 + 响应"这条链,大部分被它兜住了(你只需在阻塞点用 ctx)。它体现的原则是:对"一组带取消/超时语义的并发协作"这种容易写漏(忘传 ctx、忘 cancel、忘等待)的场景,优先用经过验证的成熟封装(errgroup),把易错的协作细节交给它,而非每次手搓。
写在最后
回头看,这场由"取消信号没传到、没人监听"引发的 goroutine 泄漏,真正教给我的,远不止"传 ctx、监听 ctx.Done()"这一个技巧。它让我对"'上游决定不要了/叫停了' 这个决定, 不会自动传达到所有正在为它干活的人; 如果没有一条贯通的'通知链'把'停'的信号传下去, 而且每个干活的人主动去'听'这个信号并停下, 那么——上游早已转身离开, 下游还在埋头苦干(为一件早已被放弃的事, 白白耗着、占着、最后卡死)",有了一次刻骨的体会。我栽跟头,是因为我以为"我(上游)决定放弃了", 这个决定就自然而然地生效于一切——我设了超时、超时了我就返回, 我以为"整件事就停了";可我忘了: 那些替我干活的 goroutine, 是'独立'在跑的; 它们既没被告知"上游不要了"(我没传ctx), 也没在留意有没有这种通知(没监听Done);于是"我放弃了" 这个事实, 在它们那里完全不存在——它们还在为一个早已没人要结果的任务, 一丝不苟地跑着, 跑完无处交付, 就永远卡在那里了。这让我领悟到一个关于"取消、协作与通知链"的深刻认知:在一个有多个'独立执行者'协作的系统里, "叫停一件事" 远不是"发起方自己停下"那么简单——"停" 这个意图, 必须被主动地、一路地传播给每一个正在为这件事工作的执行者, 而且每个执行者必须主动地监听并响应这个'停'的信号;这条"传播 + 监听 + 响应"的链, 缺了任何一环, "取消"就只是发起方的一厢情愿, 而真正的工作仍在底层继续——这在工程上叫"资源泄漏/做无用功", 在协作中叫"上面变了卦, 下面还在按老指令埋头干";所以"有效的取消/变更, 是一种需要刻意打通'通知链'的协作, 而非一个发起方单点的动作"。这给了我一种处理"取消/叫停/变更"时的清醒:每当我要"取消/叫停/改变"一件已经有多方在执行的事时,要意识到"我这边'决定停'是不够的——这个'停'有没有真的传到每个执行者?他们有没有在听、会不会响应?"——主动去打通那条"传播→监听→响应"的通知链, 确认取消真的被每个执行者收到并执行了, 而不是默认"我停了大家就都停了";"把取消/变更当成需要贯通通知链的协作来对待、确认信号传到且被响应",是避免'上游放弃下游还跑'式泄漏与浪费的关键。认清取消信号不会自动传达到所有执行者、有效取消需打通传播-监听-响应的通知链、缺一环就泄漏做无用功——这,是我用一次 goroutine 泄漏的事故,换来的、关于 Go 并发、也关于如何让取消真正生效的、最朴素也最深刻的领悟。如果这篇复盘,能让你下次启动一个 goroutine 时,顺手把 ctx 传进去、在它的阻塞点加上 case <-ctx.Done(),那我对着那条只涨不跌的 goroutine 曲线排查的这段时间,就值了。
—— 别看了 · 2026