
如果你写过两年以上 Go大概率见过这个场景一个 HTTP 请求进来handler 里起了三个 goroutine 去调下游服务其中两个正常返回另一个因为下游接口卡住30 秒后才结束。客户端早就断开了但这个 goroutine 还在跑如果这个接口被高频打到这种“僵尸 goroutine”会越积越多内存和连接池双双被拖垮。处理这类“跨 goroutine 传递取消信号、统一控制超时”的问题标准答案就是 context 包。context 在 Go 1.7 时被正式纳入标准库解决的问题很集中一个请求从进入到结束的完整生命周期里如何把“该停了”这个信号及时、准确地传给每一个参与方。这篇文章不打算复述官方文档而是直接按照源码行为和实际踩坑经验拆解 context 的生命周期控制逻辑最后给出生产环境里能落地的写法。里面有些细节是写 demo 时根本碰不到的。1. 为什么 Go 程序需要 context一次线上超时事故的复盘1.1 事故现场一个 goroutine 泄漏引发的雪崩去年我在维护一个 BFF 网关时赶上过一次线上事故。服务本身 QPS 不高但下游有个老接口偶尔会 hang 住不返回最长能挂几分钟。当时的代码只有http.Client.TimeoutHTTP 层超时之后 goroutine 理论上应该结束——但问题是客户端连接虽然断了goroutine 里的业务逻辑还在继续跑它还在等数据库连接、还在往一个无缓冲 channel 里发数据。积压到一定程度内存曲线陡增GC 把 CPU 吃满整个服务开始连锁超时最后只能回滚重启。这个事故的本质不是“下游慢”而是“没有一种机制告诉所有协作者这个请求已经结束了你们可以停了”。如果你只在最外层设置了超时而没有把取消信号向下传递内层的 goroutine 根本感知不到外层已经放弃。context 存在的意义就是把这根“生命线”从最外层一路牵到最内层。更隐蔽的问题是内存。一个挂起的 goroutine 会持有它的整个调用栈、当前已经分配的所有局部对象引用可能几 MB 内存都回不去。假设 QPS 是 200其中 5% 的请求触发泄漏那么每分钟就有 600 个 goroutine 被“种”进去一个小时不到服务就得 OOM。所以 context 不是性能优化是稳定性设计。1.2 context 到底解决的是哪一类问题context 覆盖三件事取消信号传播父节点取消所有衍生子节点收到通知。截止时间管理统一设置 deadline 和 timeout。请求级数据传递traceID、用户标识等沿调用链传递。注意一个边界context 不解决“业务参数怎么传”。经常有人把大对象塞进 context 当函数参数用这是对它的滥用。它更不是缓存不保证线程安全的读改写——它要求放入的值在生命周期内不可变。官方文档里那句“context 里的值应该用于辅助请求处理过程的横切信息而不是业务入参”说得已经很明白了。1.3 没有 context 之前开发者怎么处理生命周期早期 Go 项目里最常见的生命周期控制手段有这几种自定义 done channel每个函数多一个参数。全局退出标志 atomic bool select。到处都是time.After的 select 分支。这些办法的共同缺陷是没有“父子层级”的概念。A 函数停止不代表它派生的 B、C 函数也要停止要实现层级取消你得手写一套树形管理。context 本质上就是把“树形取消传播”做成了标准库并且用接口隔离了实现。它还顺带统一了终止原因——context.Canceled和context.DeadlineExceeded成为标准错误值日志、监控、错误归类都有了统一判断依据。2. Context 树取消信号如何从父节点传递到子节点2.1 一棵只向下生长的树标准库为你准备了两棵树的“根”context.Background()和context.TODO()。两者都返回emptyCtx——一个永远不会被取消、没有 deadline、没有 value 的空上下文。区别纯粹是语义上的Background 表示“我知道这里需要一个 context而且这是根”TODO 表示“我暂时还没想好是从哪个 context 派生先占个位”。Code Review 时TODO 一般会被要求换成真正从上层传入的 context。从任何一个 context 出发调用WithCancel、WithDeadline、WithTimeout、WithValue都会产生一个子节点。子节点持有父节点的引用但父节点只登记“可取消类”的子节点cancelCtx、timerCtx这样父节点取消时才能主动通知它们。这是理解整个生命周期行为的第一块基石信号永远自上而下父杀子子不能杀父。打个比方父节点像工作群里的群主群主说“散会”所有在群里的人都得走但群成员自己说“我不干了”其他人该干嘛还干嘛。2.2 WithCancel 的内部实现cancelCtx 的运作方式WithCancel(parent)返回一个 cancelCtx 包装体和 cancel 函数。核心结构可以简化成type cancelCtx struct { Context mu sync.Mutex done chan struct{} children map[canceler]struct{} err error }注意done是懒加载的只有第一次调用Done()时才初始化 channel。为什么不一开始就创建因为很多 context 从头到尾都没人监听 Done不去初始化 channel 就少一次内存分配还能避免往一个永远不会被读的 channel 上分配资源的浪费。调用cancel()时实际做四件事加锁设置 err。关闭 done channel只关一次。遍历 children map把每个子节点的 cancel 也调一遍。把自己从父节点的 children 里摘除并清空自己的 children 引用。关闭 channel 就是 Go 里最朴素的广播机制所有监听-ctx.Done()的 goroutine 都会立刻收到零值。因为 channel 关闭后读操作立即返回所以“cancel 之后新建的 goroutine”也读得到——信号不会丢。同时channel 关闭与接收之间存在 happens-before 关系接收方能看到关闭前写入的所有数据所以 context 在并发场景下是安全的。2.3 父取消、子独立树结构决定的行为既然信号只向下传播那么有两个行为值得记住父节点被取消所有后代节点全部被取消。无论你是函数里用 WithTimeout 派生的还是在 goroutine 里 WithCancel 派生的只要挂在同一棵树上都会收到。子节点取消父节点和其他兄弟节点不受影响。这意味着你可以在一个请求