3.6 Context:下班铃与任务取消


3.6 Context:下班铃与任务取消

本节摘要:context 是 Go 传递取消信号、截止时间与请求级元数据的标准机制,一个根 context 派生出一棵取消树,铃响全树皆知。本节讲四种派生方式、超时模板与值传递的规矩。学完本节,你能在任意深度的调用链上实现体面收工,这是生产级并发服务的最后一根支柱。

员工会派、会传话、会监听、会互斥了,但 3.2 节留了一个悬念:goroutine 不能被强杀,退出只能靠自查信号。那信号从哪来、怎么保证一条几十层的调用链上每个环节都听得到?答案就是本章最后一件工具——context,调度中心的下班铃:铃声沿组织树层层下达,任何一级员工听到铃都立即收工。本章到此收口,第 4 章将带着全套工具处理真实故障。

取消树:一铃响,全树停

context 的设计是树形的:根 context 由 main 或请求入口创建,每往下一层调用,就派生一个子 context;任何一层取消,它下面的整棵子树全部收到信号。四个派生函数各有分工:

  • WithCancel:手动取消,铃在自己手里。
  • WithTimeout:倒计时取消,到点自动响。
  • WithDeadline:定点取消,指定时刻响。
  • WithValue:挂请求级元数据(如追踪号),与取消无关但共用同一棵树。
package main import ( "context" "fmt" "time" ) // deepTask 模拟深层调用:每层都检查铃声 func deepTask(ctx context.Context, depth int) { select { case <-time.After(80 * time.Millisecond): // 干完自己的活 fmt.Printf("第 %d 层:正常完工\n", depth) case <-ctx.Done(): // 铃响了 fmt.Printf("第 %d 层:听到铃声,中止\n", depth) } } func main() { ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond) defer cancel() // 惯例:无论是否超时都调用,释放定时器资源 deepTask(ctx, 1) deepTask(ctx, 2) fmt.Println("调度台:本轮任务结束(部分中止)") } // 运行输出: // 第 1 层:听到铃声,中止 // 第 2 层:听到铃声,中止 // 调度台:本轮任务结束(部分中止)

三处细节值得放大。第一,ctx.Done 返回一个只收窗口,铃声就是 3.2 说的 close 广播——取消在底层正是关闭这个窗口。第二,ctx 总是作为函数第一个参数,命名就叫 ctx,这是全社区统一的签名公约,工具链与代码评审都依赖它。第三,defer cancel 不是可有可无的礼貌:不调用会泄漏定时器与 context 节点,长驻服务里积少成多。

图 3-6:一棵取消树的传导

图 3-6:一棵取消树的传导

标准超时模板:把 select 组合进来

真实业务里,"取消"往往发生在等某个耗时操作的半路上,标准姿势是把 ctx.Done 与操作结果放进同一个 select——3.4 的竞速模板在这里完成最后一块拼图:

package main import ( "context" "fmt" "time" ) func query(ctx context.Context) (string, error) { ch := make(chan string, 1) // 缓冲 1:超时后慢查询完成也有处安放 go func() { time.Sleep(120 * time.Millisecond) // 模拟慢查询 ch <- "查询结果" }() select { case r := <-ch: return r, nil case <-ctx.Done(): return "", ctx.Err() // DeadlineExceeded 或 Canceled } } func main() { ctx, cancel := context.WithTimeout(context.Background(), 60*time.Millisecond) defer cancel() r, err := query(ctx) fmt.Println("结果:", r, "错误:", err) } // 运行输出: // 结果: 错误: context deadline exceeded

ctx.Err 告诉你铃声的性质:DeadlineExceeded 是超时,Canceled 是人为取消。调用方据此区分"该重试"与"该放弃"。再啰嗦一次那个缓冲:没有它,超时返回后内部 goroutine 会卡在发送上——3.4 的教训在 context 场景里原样适用。

WithValue:能不用就不用

context 能挂键值对,沿树向下查询,典型用途是链路追踪号、鉴权身份这类"横切数据"。两条纪律:键用自定义类型而非字符串,避免跨包碰撞;只放请求级元数据,永远不要拿它传业务参数——函数参数缺席被迫去 ctx 里捞值,是依赖关系腐化的标志:

type traceKey struct{} func withTrace(ctx context.Context, id string) context.Context { return context.WithValue(ctx, traceKey{}, id) // 自定义类型作键 } func readTrace(ctx context.Context) string { if v, ok := ctx.Value(traceKey{}).(string); ok { // 断言回具体类型 return v } return "无追踪号" } // 入口 withTrace 后,链路上任意一层 readTrace 都能取到同一个追踪号

完整案例:一次带全链路超时的巡检

背景:巡检一个服务要连做三步:取地址、探活、记录结果。要求整轮预算一百毫秒,任何一步卡住都不能拖垮整体。

操作:入口建根 context 与总预算;每步用同一个 ctx 派生自己的操作;探活慢于预算时全链立即中止。

package main import ( "context" "fmt" "time" ) func probe(ctx context.Context) (string, error) { select { case <-time.After(200 * time.Millisecond): // 探活本身要 200ms return "存活", nil case <-ctx.Done(): return "", fmt.Errorf("探活中止: %w", ctx.Err()) } } func main() { start := time.Now() ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond) defer cancel() status, err := probe(ctx) fmt.Println("状态:", status, "错误:", err, "总耗时:", time.Since(start).Round(time.Millisecond)) } // 运行输出: // 状态: 错误: 探活中止: context deadline exceeded 总耗时: 100ms

结果:探活虽需两百毫秒,但整轮在预算到期的一刻准时收口,总耗时几乎精确等于预算。

解读:错误信息经百分号 w 包装后同时呈现业务动作与根因,沿 1.5 的链路惯例输出。这里没有锁、没有共享状态,取消能力却贯穿了整条调用链——context 与 channel 组合的典型气质:结构简单,语义完整。

变式:三步巡检改成三路并发(取地址与探活同时进行),任一路失败即取消另一路。实现需要 errgroup 或手写的取消联动,手写一遍会让"树形取消"的成本模型完全透明——这个练习正是 4.1 的开胃菜。

收班要点

  • context 是取消树:四种派生函数分工明确,取消只向下游传导。
  • ctx 永远是第一个参数,Done 返回只收窗口,Err 区分超时与人为取消。
  • defer cancel 必写,不调用的 cancel 就是定时器泄漏。
  • 标准超时模板:操作结果与 ctx.Done 同 select,内部窗口记得带缓冲。
  • WithValue 只放请求级元数据,键用自定义类型,业务参数走函数签名。

并发基础至此集齐五件套:派活、传话、监听、守护、收工。下一章进入真实战场——并发模式、竞态调查、性能观测与那份实战淬炼出的排错手册。


作者与出处
原作者: 灏天文库
来源:灏天文库
整理: 灏天文库整理
由灏天文库平台收录,内容或由平台用户上传,仅供学习交流
发布者: 作者: 灏天文库 转发
评论区 (0)
U