12.1 context 的取消与传播
第 10 章我们用 done channel 让扫描器可以被停止,第 11 章让 store 变得并发安全。但还有一个问题没解决:批量任务一旦开始,就没法中途叫停。
假设用户点了「关闭所有到期任务」,结果发现有 5000 个任务要被关掉,想反悔——现在的代码只能等它跑完。再比如 HTTP 请求(第 13 章)的客户端断开连接了,服务器还在傻乎乎地跑数据库查询。这些场景需要的都是同一件事:把一个「取消信号」从发起方传给所有参与方,并让它们自己决定怎么停。
Go 的答案是 context 包。
本节把 TaskAPI 推进到:批量关闭任务支持
context取消,收到取消信号时停止处理剩余任务并返回已完成数量。
12.1.1 为什么不用 channel 就够了
10.1 节的 done channel 确实能传递停止信号。但真实场景里它不够用,原因有三个:
- 一对多。一个请求可能派生出多个 goroutine(查库、查缓存、调第三方),取消要能同时通知所有人。
- 要能带信息。调用方需要知道「是被取消了,还是超时了」,才能决定重试还是放弃。
- 要能跨 API 边界。你调用别人的函数、别人调用你的函数,需要一个所有 Go 程序员都认识的约定——不能每个库都自己定义一套停止信号。
context.Context 就是那个标准约定。它把「取消信号」包装成一个接口,同时承载取消原因和截止时间,并支持树形传播。
12.1.2 Context 接口长什么样
context.Context 只有四个方法:
type Context interface {
Deadline() (deadline time.Time, ok bool) // 有截止时间吗
Done() <-chan struct{} // 取消时关闭的 channel
Err() error // 为什么被取消
Value(key any) any // 携带的请求级数据
}
逐个理解:
| 方法 | 返回 | 用途 |
|---|---|---|
Done() | 一个 channel,取消时被关闭 | 放进 select 里等待取消 |
Err() | nil / context.Canceled / context.DeadlineExceeded | 判断取消原因 |
Deadline() | 截止时间与「有没有设置」 | 决定还剩多少时间预算 |
Value() | 键对应的值 | 传请求级数据(不是传参数!) |
Done() 返回的是一个只读 channel,这一点非常关键:它复用了第 10.2 节学的「关闭即广播」语义。close 一个 channel 会同时唤醒所有在它上面等待的 goroutine,所以一个 ctx 可以被任意多个 goroutine 共享——这正是「一对多取消」的实现方式。
Err() 返回值的规则很简单:
Done()还没关闭 →Err()返回nil;- 被
cancel()主动取消 →context.Canceled; - 超过截止时间 →
context.DeadlineExceeded。
12.1.3 WithCancel:最基础的取消
context 包提供两个「根节点」:
ctx := context.Background() // 空的、永不取消的根 context
ctx := context.TODO() // 同上,语义是「我还没想好这里该用什么」
实际写代码时,Background() 用在 main、初始化、测试这些真正的入口;TODO() 用作占位,提醒自己这里以后要换成真正的 ctx。
WithCancel 从一个父 context 派生出子 context:
ctx, cancel := context.WithCancel(context.Background())
defer cancel() // 关键:一定要调用
子 ctx 的 Done() 会在两种情况下关闭:调用 cancel(),或者父 ctx 被取消。这就是「传播」。
用一个完整的例子感受:
func processBatch(ctx context.Context, ids []int64) {
for _, id := range ids {
select {
case <-ctx.Done():
fmt.Printf("取消原因: %v,已处理 %d 个\n", ctx.Err(), id-1)
return
default:
}
time.Sleep(20 * time.Millisecond)
fmt.Println("处理任务 #", id)
}
}
func main() {
ctx, cancel := context.WithCancel(context.Background())
go processBatch(ctx, []int64{1, 2, 3, 4, 5})
time.Sleep(50 * time.Millisecond)
cancel() // 通知所有子 goroutine 停止
time.Sleep(30 * time.Millisecond)
fmt.Println("主流程结束, ctx.Err() =", ctx.Err())
}
实测输出:
处理任务 # 1
处理任务 # 2
处理任务 # 3
取消原因: context canceled,已处理 3 个
主流程结束, ctx.Err() = context canceled
注意 processBatch 里的检查模式:
select {
case <-ctx.Done():
return // 已取消,提前退出
default:
// 继续干活
}
这是非阻塞地检查取消的标准写法。select 加 default,有取消就走取消分支,没有就立刻走 default 继续——不会因为检查取消而卡住。这个模式会在整个第 12 章反复出现。
12.1.4 传播:ctx 是函数的第一个参数
Go 的约定是:任何可能阻塞或耗时的函数,第一个参数都应该是 ctx context.Context。
func (s *MemStore) Save(ctx context.Context, t Task) error
func fetchRemote(ctx context.Context, url string) (*Response, error)
func CloseDueTasks(ctx context.Context, tasks []Task) (int, error)
这不是风格偏好,而是让取消能够穿透调用链。假设 CloseDueTasks 内部要调 fetchRemote,如果它没接收 ctx,就没法把取消传下去,调用方想中途停就只能干等。
传播的规则可以总结成三条:
- 接收:函数签名第一个参数是
ctx,命名为ctx(不要叫c、context)。 - 传递:调用下游函数时,把同一个
ctx原样传下去,不要用context.Background()顶替。 - 派生:只有当这一层需要额外的取消条件或超时时,才用
WithCancel/WithTimeout派生新的子 ctx。
第 2 条是最容易犯的错。下面这段代码看起来没问题,实际上把取消链切断了:
func handler(ctx context.Context) {
doWork(context.Background()) // 错误:丢弃了 ctx,调用方再也取消不了 doWork
}
一旦在某一层用了 context.Background(),上游的取消信号就传不下来了。
12.1.5 cancel 必须调用
WithCancel 返回的 cancel 函数必须被调用,哪怕你确信不会提前取消。原因不是「怕忘记取消」,而是资源释放:
WithCancel 会在可取消的父 context 内部注册一个子节点(父 ctx 的 Done() 为 nil 时,比如 context.Background(),则什么也不注册)。如果不调用 cancel,这个注册关系会一直保留,直到父 ctx 自己被取消。于是每调用一次不 cancel 的 WithCancel,就可能泄漏一份挂在父节点上的注册关系——注意这是内存泄漏,WithCancel 本身并不启动 goroutine,用 runtime.NumGoroutine() 是看不出来的。
go vet 会直接抓到这种遗漏:
main.go:11:7: the cancel function returned by context.WithCancel should be called, not discarded, to avoid a context leak
所以标准写法永远是:
ctx, cancel := context.WithCancel(parent)
defer cancel() // 紧跟在派生之后
把 defer cancel() 写在派生语句的下一行,和 defer mu.Unlock() 跟在 mu.Lock() 后面是同一个道理——让释放动作和获取动作挨在一起,中间无论怎么 return 都不会漏。
一个常见的疑惑是:「我明明会在某个分支里手动 cancel,为什么还要 defer?」答案是 defer cancel() 是幂等的,重复调用没有任何副作用。手动 cancel 提前触发了取消,defer 那次只是白白调用一次。所以永远 defer,不要省略。
12.1.6 不要做这三件事
| 反模式 | 为什么错 | 正确做法 |
|---|---|---|
把 ctx 存进结构体字段 | ctx 有生命周期,属于某次调用而非某个对象 | 作为方法参数传入 |
传 nil 当 ctx | 下游调用 ctx.Done() 直接 panic | 用 context.TODO() |
用 ctx.Value 传业务参数 | Value 没有类型安全,签名也不再自描述 | 显式作为函数参数 |
第一条尤其值得强调。ctx 的生命周期通常是「一次请求」或「一次操作」,把它塞进长生命周期的结构体里,就会出现「这个对象到底该用哪个 ctx」的混乱。正确的做法是每次都从参数拿:
// 不好
type Service struct {
ctx context.Context // 这个 ctx 什么时候失效?
store *MemStore
}
// 好
type Service struct {
store *MemStore
}
func (s *Service) Do(ctx context.Context, id int64) error { ... }
第三条也值得展开:Value 是为跨 API 边界的请求级元数据设计的,典型用途是 request ID、认证信息、trace 上下文。它不该被用来传「函数本来就应该接收的参数」——因为 Value 的键是 any,取值要做类型断言,一旦键名拼错或类型不符,编译期什么都发现不了。
12.1.7 给 TaskAPI 的批量关闭加上取消
现在把本节内容用到 TaskAPI 上:
func CloseDueTasks(ctx context.Context, tasks []Task) (int, error) {
closed := 0
for _, t := range tasks {
select {
case <-ctx.Done():
return closed, ctx.Err() // 返回已完成数量和取消原因
default:
}
time.Sleep(20 * time.Millisecond) // 模拟写库
closed++
fmt.Printf("已关闭 #%d %s\n", t.ID, t.Title)
}
return closed, nil
}
主流程在 70 毫秒后取消:
ctx, cancel := context.WithCancel(context.Background())
go func() {
time.Sleep(70 * time.Millisecond)
cancel()
}()
n, err := CloseDueTasks(ctx, tasks)
fmt.Printf("完成 %d 个, err = %v (errors.Is(err, context.Canceled)=%v)\n",
n, err, errors.Is(err, context.Canceled))
实测输出:
已关闭 #1 到期任务1
已关闭 #2 到期任务2
已关闭 #3 到期任务3
已关闭 #4 到期任务4
完成 4 个, err = context canceled (errors.Is(err, context.Canceled)=true)
这份实现有三个设计决定值得说明:
- 返回「已完成数量 + error」而不是只返回 error。调用方需要知道取消时已经处理了多少,才能决定是重试剩余部分还是整体回滚。
return closed, ctx.Err()直接返回 ctx 的错误,不包装成自定义错误。第 6 章学过errors.Is,调用方可以用errors.Is(err, context.Canceled)精确判断,而不用匹配字符串。- 取消点是循环开头。这保证「已经处理完的任务不会被回滚」,语义是「尽力而为地处理,收到取消就停」。如果需要「要么全做要么全不做」,那就得用事务——那是第 14 章的内容。
12.1.8 小结与练习
ctx.Done()是一个「关闭即广播」的 channel,天然支持一对多取消。ctx.Err()区分Canceled与DeadlineExceeded,用errors.Is判断。- ctx 是函数第一个参数;跨层传递时原样传,不要用
Background()截断。 defer cancel()紧跟派生语句,永不省略。- 不要把 ctx 放进结构体,不要传 nil,不要用
Value传业务参数。
练习:
- 把 12.1.7 的取消点从循环开头挪到
time.Sleep之后,观察已完成数量的变化,并思考两种语义的差别。 - 写一个函数,内部派生一个子 ctx 并
defer cancel(),然后用go vet ./...验证;再把defer cancel()删掉,看 vet 报什么。 - 验证「忘记 cancel」的代价:用一个长期不取消的父 ctx 循环派生 100 个不 cancel 的子 ctx,观察父节点上积累的注册关系(提示:
runtime.NumGoroutine()不会上涨,泄漏的是内存而非 goroutine)。
下一节我们给 ctx 加上时间预算和请求级数据:超时、截止时间,以及 WithValue 的正确用法。
阅读导航:上一节:11.3 用 -race 发现竞态 · 下一节:12.2 超时、截止时间与 WithValue 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。