学完这篇,你能搞懂 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。