《Go 语言编程入门》12.1 context 的取消与传播

第 11 章的批量任务一旦开始就只能跑到底,中途无法叫停。本节引入 context:讲清 Context 接口的四个方法、WithCancel 的树形传播、为什么 cancel 必须调用、为什么 ctx 只能作为第一个参数而不能塞进结构体,并让 TaskAPI 的批量关闭支持中途取消。

12.1 context 的取消与传播

第 10 章我们用 done channel 让扫描器可以被停止,第 11 章让 store 变得并发安全。但还有一个问题没解决:批量任务一旦开始,就没法中途叫停。

假设用户点了「关闭所有到期任务」,结果发现有 5000 个任务要被关掉,想反悔——现在的代码只能等它跑完。再比如 HTTP 请求(第 13 章)的客户端断开连接了,服务器还在傻乎乎地跑数据库查询。这些场景需要的都是同一件事:把一个「取消信号」从发起方传给所有参与方,并让它们自己决定怎么停。

Go 的答案是 context 包。

本节把 TaskAPI 推进到:批量关闭任务支持 context 取消,收到取消信号时停止处理剩余任务并返回已完成数量。

12.1.1 为什么不用 channel 就够了

10.1 节的 done channel 确实能传递停止信号。但真实场景里它不够用,原因有三个:

  1. 一对多。一个请求可能派生出多个 goroutine(查库、查缓存、调第三方),取消要能同时通知所有人。
  2. 要能带信息。调用方需要知道「是被取消了,还是超时了」,才能决定重试还是放弃。
  3. 要能跨 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,就没法把取消传下去,调用方想中途停就只能干等。

传播的规则可以总结成三条:

  1. 接收:函数签名第一个参数是 ctx,命名为 ctx(不要叫 c、context)。
  2. 传递:调用下游函数时,把同一个 ctx 原样传下去,不要用 context.Background() 顶替。
  3. 派生:只有当这一层需要额外的取消条件或超时时,才用 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 小结与练习

  1. ctx.Done() 是一个「关闭即广播」的 channel,天然支持一对多取消。
  2. ctx.Err() 区分 Canceled 与 DeadlineExceeded,用 errors.Is 判断。
  3. ctx 是函数第一个参数;跨层传递时原样传,不要用 Background() 截断。
  4. defer cancel() 紧跟派生语句,永不省略。
  5. 不要把 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 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「golang」更多文章

  1. 《Go 语言编程实战》目录
  2. 《Go 语言编程实战》18.3 上线、观测与迭代
  3. 《Go 语言编程实战》18.2 故障演练