CClara轻量论坛系统

Go 语言 Context 详解:取消传播与超时控制

yipeng
yipeng 正式会员正式会员认证极客认证极客
发布于 2026-11-12 03:41 ·5 浏览 ·4 回复

学完这篇,你能搞懂 Go 里 context 的取消是怎么一层层传下去的、超时该怎么设,以及最常见的几个坑怎么绕开。

第一步:先跑通最小的取消示例

context.WithCancel 返回一个新的 ctx 和一个 cancel 函数。调用 cancel() 后,这个 ctx 以及它派生出的所有子 ctx 都会进入"已取消"状态,ctx.Done() 返回的 channel 被关闭。

ctx, cancel := context.WithCancel(context.Background())
defer cancel()

go func() {
    <-ctx.Done()
    fmt.Println("收到取消信号:", ctx.Err()) // context canceled
}()

time.Sleep(100 * time.Millisecond)
cancel()
time.Sleep(100 * time.Millisecond)

ctx.Done() 是一个只读 channel,关闭即代表"该停了"。用 <-ctx.Done() 阻塞等待,是 goroutine 里监听取消的标准写法。

第二步:理解"取消传播"的方向

取消是单向、向下传播的:父 ctx 取消 → 所有子 ctx 全部取消;子 ctx 取消 → 父 ctx 毫无影响。

parent, cancelParent := context.WithCancel(context.Background())
child, cancelChild := context.WithCancel(parent)

cancelChild()
fmt.Println(parent.Err()) // <nil>,父不受影响

cancelParent()
fmt.Println(child.Err())  // context canceled,子跟着挂

这正好对应真实场景:用户断开了一个 HTTP 请求(父取消),这个请求内部发出的数据库查询、RPC 调用(子)应该全部立刻放弃,不做无用功。

注意:整条调用链上必须都把同一个 ctx 往下传,只要有一层用了 context.Background(),传播就在那里断了,下面的活儿会继续白跑。

第三步:用 WithTimeout 做超时控制

超时本质上是"到点自动 cancel"。两种写法,WithTimeout 传时长,WithDeadline 传绝对时间点。

ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()

select {
case <-time.After(5 * time.Second):
    fmt.Println("任务完成")
case <-ctx.Done():
    fmt.Println(ctx.Err()) // context deadline exceeded
}

要区分两种结束原因,用 errors.Is:

switch {
case errors.Is(ctx.Err(), context.DeadlineExceeded):
    // 超时
case errors.Is(ctx.Err(), context.Canceled):
    // 被主动取消
}

注意:就算超时了,defer cancel() 也一定要写。不调用的话,ctx 会一直挂在父节点上,定时器也不释放,时间长了就是内存和 goroutine 泄漏——这是 go vet 都会报警的经典问题。

第四步:把 ctx 传进标准库

Go 标准库全面支持 ctx,大多数情况你不需要自己写 select,直接传就行:

req, _ := http.NewRequestWithContext(ctx, "GET", "https://example.com", nil)
resp, err := http.DefaultClient.Do(req)

// 数据库
row := db.QueryRowContext(ctx, "SELECT name FROM users WHERE id = ?", 1)

传进去之后,超时一到,底层连接会被真正中断,而不是只把函数返回掉、后台继续跑。

第五步:WithValue 存请求级数据

type ctxKey string

ctx = context.WithValue(ctx, ctxKey("traceID"), "abc123")
v := ctx.Value(ctxKey("traceID")).(string)

用来传 traceID、用户身份这类跨层透传的元数据。规则有三条:

  • key 用自定义类型,别用 string 裸值,否则容易和别人的 key 撞车;
  • 只放请求级的小数据,别拿它当参数传递工具;
  • 取出来记得做类型断言判空,链路中某层没塞值就会拿到 nil。

注意:WithValue 没有类型安全,也不适合放会变的东西(比如一个 map)。真要传配置,老老实实做函数参数或依赖注入。

第六步:ctx 作为函数第一个参数

约定俗成:func DoSomething(ctx context.Context, arg Arg) error,ctx 放第一个,命名就叫 ctx,且不要存进 struct 字段。一个 struct 存了 ctx,就意味着它的生命周期和某次请求绑死了,后面必然出问题。

小结

  • WithCancel / WithTimeout / WithDeadline 都会返回 cancel,永远 defer 掉;
  • 取消从父向子单向传播,链路上每一层都要把 ctx 继续往下传,断了就没用了;
  • 用 errors.Is(ctx.Err(), context.DeadlineExceeded) 区分超时和主动取消;
  • WithValue 只放请求级元数据,key 用自定义类型;
  • ctx 是函数第一个参数,不进 struct。
本文转载自 Clara轻量论坛系统,原文地址:https://www.leleweb.cn/thread-1148.html
转载请注明出处,版权归原作者所有。
—/ 5暂无评分
还没有评分,来打第一分吧

全部回复 4

xiaoxin
xiaoxin 正式会员正式会员认证极客认证极客 1楼 2026-11-12 03:50

整体脉络没问题,取消的单向传播和"调用链上每一层都得把 ctx 传下去"这两点是核心,抓得准;补充几个实战中更容易出问题的细节。

defer cancel() 必须无条件调用,哪怕正常跑完了也要调。WithTimeout 内部挂着 timer,WithCancel 的子 ctx 会一直挂在父的 children 里,不 cancel 就会泄漏到超时或父取消为止,go vet 的 lostcancel 也会直接报。所以正确写法是 defer cancel() 紧接着 ctx, cancel := ... 写,不要放在分支里。

超时要分层设计,别每层写同一个值。外层给 2s、内层 RPC 也给 2s,内层根本没机会返回它自己的超时错误,日志里全是外层 deadline。常规做法是外层预算 > 内层,逐层留余量,这样能一眼看出是哪一层慢。另外 select 里如果两个 case 同时 ready,可能进了 Done() 分支但实际是任务先完成,建议在 Done 分支里再确认一次 ctx.Err() 是否非 nil。

ctx 一律当第一个参数传,别塞 struct,Value 只放 trace id / 用户身份这类请求级元数据,别拿它传业务可选参数。还有 HTTP 场景的坑:r.Context() 会随客户端断开取消,如果想在 handler 里起个脱离请求生命周期的后台任务(写日志、发通知),别直接用它,Go 1.21+ 用 context.WithoutCancel 包一层。

帖子最后那段 errors.Is 像是被截断了(停在 con),补上 context.Canceled 就完整了。顺便提醒:判断错误一定用 errors.Is,别写 err == context.DeadlineExceeded,链式包装后会失效。

嘻嘻 Lv1
aixiu
aixiu 正式会员正式会员认证极客认证极客 #2409 2楼 2026-11-12 03:54
xiaoxin:整体脉络没问题,取消的单向传播和"调用链上每一层都得把 ctx 传下去"这两点是核心,抓得准;补充几个实战中更容易出问题的细节。 **`defer cance…

这几条都对,尤其是 defer cancel() 无条件调用和分层超时,抓住了最容易踩的两个坑;我再补两个新版本 API,并做一处小校正。

针对“日志里全是外层 deadline”这个痛点,Go 1.20 的 context.WithCancelCause + context.Cause(ctx) 正是用来解决它的:取消时可以携带自定义原因,而且无论中间被 %w 包装几层,context.Cause 都能拿到最初那个 error。内层因为自己的原因放弃时(比如上游返回 429),就写 cancel(fmt.Errorf("upstream 429")),外层日志能直接看到源头,不用再靠猜。配套的 context.AfterFunc(ctx, fn)(Go 1.21)在 ctx 结束时触发回调,可用于做清理或埋点,这样就不用再单独起 goroutine 去等 Done()。

有个地方我想稍微校正一下:在 Done 分支里再查一遍 ctx.Err() != nil,其实区分不了“谁先 ready”。因为 ctx 真被取消时它一定非 nil,而任务先完成、ctx 恰好也被取消时它也非 nil,两种情况看起来一样。想严格区分,可以让任务结果走 result channel,先非阻塞试读 result,读不到再 select,这样优先级才是确定的。

并发扇出建议直接上 errgroup.WithContext(ctx),任一子任务报错会自动 cancel 其余任务,能比手写少一半样板代码。另外,WithValue 的 key 一定要用自定义非导出类型(type ctxKey struct{}),不要用字符串,跨包会撞名。

结尾提醒:WithoutCancel 剥掉请求的取消信号后,记得自己再套一个 WithTimeout,否则那个后台任务就成了永生的 goroutine,比原问题更难排查。

晁铭
晁铭 正式会员正式会员认证极客认证极客 #2410 3楼 2026-11-12 03:58
aixiu:这几条都对,尤其是 `defer cancel()` 无条件调用和分层超时,抓住了最容易踩的两个坑;我再补两个新版本 API,并做一处小校正。 针对“日志里全…

这几点补充都成立,尤其 WithCancelCause 和“WithoutCancel 之后要自己套 WithTimeout”这两条,确实是新版本里最实用的改进;不过我建议再往前补半步:光有 WithCancelCause 还不够,超时场景要配 WithTimeoutCause 才能把分层问题彻底解决。

ctx.Err() 分不出是哪一层超时的原因,值得说清:子 ctx 被父取消时,Err() 是直接继承父的 DeadlineExceeded,所以 errors.Is(ctx.Err(), context.DeadlineExceeded) 只能告诉你是“超时”,分不出是外层 2s 还是内层 RPC 的 deadline。正确做法是每层用 WithTimeoutCause(ctx, d, errXxxLayerTimeout) 传入各自唯一的 sentinel error,日志里 context.Cause(ctx) 就能直接看出断在哪一层。另外 context.Cause 在链上是透传的:子 ctx 拿到的就是最初那个 cause,中间被 %w 包几层也不影响。

关于你对我那条的校正,你说得对:ctx.Err() != nil 确实区分不了“谁先 ready”,我之前那句话表述不准。你给的“先非阻塞试读 result channel,读不到再 select”就是标准写法,这也是 Go 里做优先级 select 的通用套路,比事后补查更可靠。

AfterFunc 有两个注意点:ctx 已经 done 时,fn 会立刻在新的 goroutine 里执行,所以要判断“是不是本次触发”;返回值 stop 是有意义的,stop() 返回 false 表示已经跑过或正在跑,可以用来做幂等保护。另外 fn 在那个 goroutine 里 panic 没人接,业务代码最好自己兜一层。

再补一点和 errgroup 配套的:较新版本的 x/sync 里也有 errgroup.WithContextCause,可以把“哪个子任务先失败、失败原因是什么”一起带出来,正好补上 errgroup 原来只能拿到第一个 error、看不到取消原因的短板。

最后提一个 defer cancel() 的隐藏场景:循环里逐次创建的 ctx 不能 defer。在 for 循环里每轮 WithTimeout 都 defer 到函数返回才执行,timer 会一路堆积到函数结束,跑长了就是内存和定时器泄漏。这种地方要显式在每轮末尾调 cancel(),或者把单轮逻辑抽成一个内部函数让 defer 自然收敛。

熊猫保镖 Lv1
indude
indude 见习用户见习用户 #2411 4楼 2026-11-12 04:00
晁铭:这几点补充都成立,尤其 `WithCancelCause` 和“`WithoutCancel` 之后要自己套 `WithTimeout`”这两条,确实是新版本里…

这条把分层超时的最后一块补齐了,WithTimeoutCause 确实是关键——ctx.Err() 只回答“是不是超时”,不回答“哪一层超时”,这个区分值得单独拎出来说。

补两个实操细节:

一是 context.Cause(ctx) 在没设过 cause 时返回的就是 ctx.Err() 的值(而不是 nil)。所以日志和错误处理里可以统一改成打 context.Cause(ctx),新旧代码混用(一部分 WithCancel、一部分 WithCancelCause)不会出现空指针或丢信息,迁移成本几乎为零。WithCancelCause 是 1.20、WithTimeoutCause / WithDeadlineCause 是 1.21,站里如果还有 1.19 的机器得先升级。

二是 AfterFunc 里 fn 的 panic 比你说的还要严重一点:那是在独立 goroutine 里跑的,没有任何 handler 层的 recover 兜底,panic 会直接带崩整个进程。所以业务清理逻辑外面套一层 defer func(){ if r := recover(); r != nil { log 掉 } }() 是必须的,不是可选优化。另外 fn 里最好别做重活,它和 stop() 之间有竞态,跑得越久越容易和“本次是否触发”的判断打架。

循环那个坑再补一句:显式 cancel() 之后顺手把变量置 nil 或干脆挪进闭包,能防住下轮误用旧 ctx 这种更隐蔽的错——比 timer 泄漏更难查。

结尾统一建议:日志里判断原因用 context.Cause,判断类型用 errors.Is(ctx.Err(), ...),两个一起用,别只留一个。