写并发程序时,经常会遇到同一个问题:发起方已经不需要结果了,后台任务该怎样尽快停下来?

例如,一个 HTTP 请求会调用数据库和远程服务;如果客户端已经断开连接,继续执行这些工作通常只会浪费连接、CPU 和 goroutine。Go 的 context 就是用来把“这个请求还要不要继续”“最晚什么时候结束”“调用链上有哪些请求元数据”统一传下去的机制。

可以先把它理解为一张随调用链向下传递的“请求说明书”:

  • 请求取消了,说明书会通知下游任务收尾;
  • 到了超时时间,说明书会自动发出取消通知;
  • 需要传递少量请求范围的信息时,下游可以从说明书中读取。

不会强制杀掉 goroutinecontext 只负责发通知;收到通知后是否退出,仍要由代码主动处理。

context 能解决什么问题

context 最常用于下面三类事情:

场景作用常见例子
取消告诉下游“不必继续了”客户端断开、上游提前返回
超时或截止时间为一次操作设定最长执行时间数据库查询最多 2 秒
请求范围的数据在调用链中传递少量元数据trace ID、认证身份

前两类是 context 最重要的职责。WithValue 虽然也常用,但它不适合代替函数参数或配置对象。

先认识 Context 接口

context.Context 是一个只读接口,核心方法不多:

1
2
3
4
5
6
type Context interface {
Deadline() (deadline time.Time, ok bool)
Done() <-chan struct{}
Err() error
Value(key any) any
}

可以这样记:

  • Done():一个通知通道。Context 被取消或超时时,这个通道会被关闭。
  • Err():告诉你结束原因,通常是 context.Canceledcontext.DeadlineExceeded
  • Deadline():获取最晚结束时间;没有设置时,okfalse
  • Value():读取沿调用链传递的请求范围数据。

实际代码中最常用的是 Done():把它放进 select,就能在等待工作结果的同时响应取消。

Context 是一棵向下传播的树

Context 通常从一个父 Context 派生出子 Context。父节点结束时,所有后代都会收到通知;但取消一个子节点,不会反过来取消父节点或兄弟节点。

Context 由父节点派生子节点;父级取消向下传播,子级取消不影响父级或兄弟节点,而 Value 沿父链查找

这正好契合调用链:一个请求作为父节点,数据库查询、RPC 调用和并发子任务都使用它派生出的子节点。Value 的查找也沿父链向上进行,因此子节点能读取父节点保存的请求元数据。

从哪里创建 Context

常用构造方式如下:

函数什么时候用
context.Background()程序入口、初始化、测试等没有父请求的地方
context.TODO()暂时不确定该传哪个 Context 时的占位符,应尽快替换
context.WithCancel(parent)需要主动结束一组下游工作
context.WithTimeout(parent, timeout)最常用,为操作设置最长执行时间
context.WithDeadline(parent, deadline)已经有明确的结束时刻
context.WithValue(parent, key, value)传递请求范围数据

在业务调用链中,不要反复创建 Background();优先接收并继续传递上游给你的 ctx。只有确实需要为局部工作增加取消或超时策略时,才从它派生子 Context。

最小示例:手动取消 goroutine

下面的 goroutine 一直等待取消通知。调用 cancel() 后,Done() 通道关闭,goroutine 得以退出。

1
2
3
4
5
6
7
8
9
10
ctx, cancel := context.WithCancel(context.Background())
defer cancel()

go func() {
<-ctx.Done()
fmt.Println("goroutine exit:", ctx.Err())
}()

// 例如:调用方已经拿到答案,不再需要后台工作。
cancel()

这里有两个细节:

  1. cancel 可以安全地多次调用,因此通常紧跟 defer cancel()
  2. 取消不是中断指令。只有 goroutine 像上例一样监听 ctx.Done(),它才会退出。

超时:在“完成”和“放弃”之间二选一

实际服务更常设置超时。下面的操作需要 3 秒,但 Context 只允许 2 秒,因此会走超时分支:

1
2
3
4
5
6
7
8
9
10
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()

select {
case <-time.After(3 * time.Second):
fmt.Println("operation done")
case <-ctx.Done():
fmt.Println("operation stopped:", ctx.Err())
// 输出:context deadline exceeded
}

即使操作提前完成,也应保留 defer cancel():它能及时释放计时器和派生 Context 关联的资源。WithDeadline 的行为相同,只是它接收一个具体时间点,而不是一段时长。

WithTimeout 的两条时序:操作先完成时仍应 defer cancel,deadline 先到时 Done 会通知调用方和工作 goroutine 主动收尾

在请求链路中正确传递

Web 服务中,r.Context() 已经包含客户端连接的生命周期。好的做法是把它作为每层函数的第一个参数,一路传到数据库、RPC 和并发任务:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
func handle(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 2*time.Second)
defer cancel()

user, err := userService.Get(ctx, "42")
if err != nil {
http.Error(w, err.Error(), http.StatusGatewayTimeout)
return
}
_ = json.NewEncoder(w).Encode(user)
}

func (s *UserService) Get(ctx context.Context, id string) (User, error) {
// 支持 Context 的下游 API 才能真正响应取消。
return s.repo.FindByID(ctx, id)
}

如果自行启动 goroutine,循环、发送结果和等待结果的每一端也都要考虑 ctx.Done()。否则即使 Handler 已经因超时返回,后台 goroutine 仍可能卡在发送或接收操作上。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
func doWork(ctx context.Context, result chan<- string) {
for {
select {
case <-ctx.Done():
return
default:
}

value := computeOnce()
select {
case result <- value:
case <-ctx.Done():
return
}
}
}

请求 context 沿 HTTP Handler、Service、可取消 I O 和并发子任务传递;Value 仅承载请求范围元数据,取消时各分支必须主动退出

WithValue:只放请求范围的元数据

WithValue 适合传递 trace ID、认证身份等“请求经过很多层都可能需要,但又不属于某个业务函数核心输入”的信息。Key 应使用自定义的私有类型,避免不同包的字符串 Key 意外冲突:

1
2
3
4
5
6
7
8
9
10
type userIDKey struct{}

func withUserID(ctx context.Context, id int64) context.Context {
return context.WithValue(ctx, userIDKey{}, id)
}

func userIDFromContext(ctx context.Context) (int64, bool) {
id, ok := ctx.Value(userIDKey{}).(int64)
return id, ok
}

不要用它传数据库连接、可选配置、业务入参或大量数据。这些内容应该显式写在函数参数或结构体中,调用关系才清晰,也更容易测试。

常见错误与约定

建议原因
ctx 放在函数第一个参数调用者能立刻看到该操作受取消和超时控制
不要传 nil Context不需要取消时使用 context.Background()
不要把 Context 存进结构体Context 属于一次调用,而不是长期对象的状态
创建后总是调用 cancel即使自然超时,也能更早释放资源
不要用 WithValue 传业务参数显式参数更安全、可读、可发现
让阻塞点响应 ctx.Done()否则取消只能发出,无法让任务真正离场

标准库中已经有许多支持 Context 的 API:http.NewRequestWithContextsql.DB.QueryContextexec.CommandContext,以及大量数据库、RPC 客户端的方法。优先使用这些带 Context 的版本,而不是只在最外层设置超时。

总结

context 的重点不在“存数据”,而在管理一次请求及其派生工作的生命周期

  1. 从入口拿到 ctx,并一路向下传递。
  2. 需要局部超时或取消策略时,用 WithCancelWithTimeoutWithDeadline 派生子 Context。
  3. 在 goroutine、通道、I/O 等可能阻塞的地方主动处理 ctx.Done()
  4. WithValue 传递少量请求元数据,不把它当成参数包。

掌握这四点,就能让请求结束时的下游工作有序收尾,避免无意义的执行和 goroutine 泄漏。