我的 HTTP 请求明明已经超时返回了,可它在后台启动的 goroutine 还在埋头跑、下游调用也没停,goroutine 越积越多内存一路涨,因为我没把 context 的取消信号传下去也没人监听它的深度复盘

我有个接口处理请求时会启动一些 goroutine 做并行子任务,并给请求设了超时。线上现象诡异:有些请求超时返回了,可监控显示 goroutine 数量只增不减、内存一路缓慢上涨。排查发现那些超时的请求主流程返回了,但它启动的 goroutine 还在后台埋头跑(还在查早已没人要结果的下游)、跑完没人收、白白耗着然后泄漏。复盘才搞懂:Go 用 context 传播取消/超时,请求超时时 ctx 会被 cancel、ctx.Done() 这个 channel 关闭,这是在通知别干了上游不要了;但这个通知不会自动作用到任何 goroutine——必须做两件事它才生效:把 ctx 一路传递给所有该取消的 goroutine 和下游调用,且每个 goroutine 在阻塞处主动监听 ctx.Done() 收到就停下。我两件都没做,于是请求超时、ctx 被 cancel 了,可那些 goroutine 根本不知道、也没在听,继续跑、往无人收的 channel 发送永久阻塞、泄漏。这篇复盘从故障现场讲到 context 取消机制、生效的两个必要条件(传播+监听)、goroutine 泄漏成因,再到 ctx 一路传递、阻塞点监听 ctx.Done() 或用 ctx 版本 API、channel 带缓冲、defer cancel、用 errgroup、pprof 监控的完整正解,以及其他控制信号没传播没人响应的坑,和取消信号不会自动传达到所有执行者、有效取消需打通传播-监听-响应的通知链、缺一环就泄漏做无用功的认知。

我的 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 并发时,刻进骨子里的几条铁律:

  1. context 的取消/超时信号不会自动停掉 goroutine——它只广播"该停了"的通知,是协作式取消。
  2. 取消要生效,必须:① ctx 一路传播给该取消的 goroutine/下游;② 每个 goroutine 主动监听 ctx.Done() 并停下。
  3. 启动的 goroutine 要能被取消退出;阻塞点用 select 监听 ctx.Done() 或用 ctx 版本 API。
  4. 下游调用用 ctx 版本(http.NewRequestWithContext / db.QueryContext),上游取消时自动中止。
  5. channel 收发配 select+ctx.Done() 或带缓冲,避免无人收时永久阻塞导致泄漏。
  6. WithTimeout/WithCancel 必须 defer cancel(),否则 ctx 关联资源泄漏。
  7. 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
声明:本站所有文章,如无特殊说明或标注,均为本站原创发布。任何个人或组织,在未征得本站同意时,禁止复制、盗用、采集、发布本站内容到任何网站、书籍等各类媒体平台。如若本站内容侵犯了原著者的合法权益,可联系我们进行处理 邮箱1846861578@qq.com。
技术教程

我想判断一个计算结果是不是 NaN,顺手写了 x === NaN,结果它永远返回 false、NaN 一路混了过去到处传播,因为 NaN 是唯一一个连等于它自己都不成立的值的深度复盘

2026-6-3 2:59:10

技术教程

我用 ThreadLocal 存当前登录用户,本以为线程私有绝不会串,结果某个用户偶尔会看到另一个用户的数据,因为在线程池里线程是复用的、用完没清的 ThreadLocal 残留给了下一个请求的深度复盘

2026-6-3 3:11:23

0 条回复 A文章作者 M管理员
    暂无讨论,说说你的看法吧
个人中心
购物车
优惠劵
今日签到
有新私信 私信列表
搜索