2. context 核心机制
2. context 核心机制
上一篇讲了五个创建函数。这一篇讲真正让 context 工作起来的东西:怎么监听取消信号、怎么判断为什么被取消、在 HTTP 链路里怎么传播、以及父子之间的传播规则。
select + ctx.Done():最核心的使用模式
ctx.Done() 返回一个 <-chan struct{}。未取消时读取会阻塞,取消后通道关闭,读取立即返回零值。这个特性决定了 context 的标准用法是 select:
select {
case result := <-resultCh:
return result, nil
case <-ctx.Done():
return "", ctx.Err()
}
select 让”等待业务结果”和”等待取消信号”同时进行,谁先到走谁的分支。这是 context 的灵魂,几乎所有用法都是这个模式的变体。
模式一:for-select 循环
长跑型 goroutine,循环里检查取消:
go func() {
time.Sleep(500 * time.Millisecond)
cancel()
}()
for i := 1; ; i++ {
select {
case <-ctx.Done():
fmt.Printf("第 %d 次检测到取消,退出\n", i)
return
default:
fmt.Printf("处理任务 %d\n", i)
time.Sleep(150 * time.Millisecond)
}
}
注意 default 分支让 select 变成非阻塞——能干活就干活,没活干且收到取消就退出。
模式二:等待结果或取消
封装一个可能超时的操作:
func doWork(ctx context.Context) (string, error) {
resultCh := make(chan string, 1)
go func() {
time.Sleep(200 * time.Millisecond)
resultCh <- "工作完成"
}()
select {
case r := <-resultCh:
return r, nil
case <-ctx.Done():
return "", ctx.Err()
}
}
业务 goroutine 把结果发到 channel,主流程在结果和取消之间二选一。关键:channel 一定要有 buffer 或有接收方,否则业务 goroutine 会在 resultCh <- ... 永久阻塞,导致泄漏。
模式三:级联 goroutine
父 context 取消时,所有持有它的 goroutine 同时收到信号:
go func(ctx context.Context) {
fmt.Println("A 启动")
go func(ctx context.Context) {
fmt.Println("B 启动")
go func(ctx context.Context) {
fmt.Println("C 启动")
<-ctx.Done()
fmt.Println("C 退出")
}(ctx)
<-ctx.Done()
fmt.Println("B 退出")
}(ctx)
<-ctx.Done()
fmt.Println("A 退出")
}(ctx)
time.Sleep(100 * time.Millisecond)
cancel() // A、B、C 同时退出
这正是 context 取消传播的价值:一次 cancel(),整棵 goroutine 树优雅退出。
区分 Canceled 与 DeadlineExceeded
context 取消后,ctx.Err() 返回两个标准错误之一:
context.Canceled:被主动取消(调用了cancel())context.DeadlineExceeded:超时自动取消
判断方式有三种,推荐 errors.Is:
err := ctx.Err()
fmt.Println(err == context.Canceled) // 直接比较
fmt.Println(errors.Is(err, context.Canceled)) // 推荐,支持 Unwrap
为什么强调区分?因为业务上两种错误往往需要不同处理:
err := doRequest(ctx)
if errors.Is(err, context.DeadlineExceeded) {
// 超时:可能只是后端慢,可以重试
retry()
} else if errors.Is(err, context.Canceled) {
// 用户主动取消:不要重试,用户已经走开了
return
}
一个反直觉的点值得记住:用 WithTimeout 创建的 context,如果被提前 cancel(),Err() 返回的是 Canceled 而不是 DeadlineExceeded。错误类型只反映”实际触发取消的原因”。
HTTP 服务里的 context
Go 的 net/http 原生支持 context:
r.Context()返回请求的 context- 客户端断开连接时,context 自动取消
- 中间件可以用
WithValue/WithTimeout包装请求 context
func handler(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
select {
case <-time.After(2 * time.Second):
fmt.Fprintln(w, "处理完成")
case <-ctx.Done():
// 客户端断开 → ctx.Done() 关闭
log.Printf("客户端断开: %v", ctx.Err())
}
}
这个机制很关键:不需要写心跳检测,HTTP 框架替你做了。客户端断开 → context 取消 → handler 里的 <-ctx.Done() 立即返回,避免服务端空转。
中间件注入值
中间件层层包装 context,handler 在最内层能拿到所有值:
type Middleware func(ctx context.Context) context.Context
logging := func(ctx context.Context) context.Context {
return context.WithValue(ctx, "trace_id", "trace-abc")
}
auth := func(ctx context.Context) context.Context {
return context.WithValue(ctx, "user_id", "user-42")
}
ctx := auth(logging(context.Background()))
fmt.Println(ctx.Value("trace_id")) // trace-abc
fmt.Println(ctx.Value("user_id")) // user-42
数据库查询超时
实际项目里常见的链路:HTTP handler 给整体请求设超时,给 DB 查询设更短的超时。
handlerCtx, _ := context.WithTimeout(context.Background(), 300*time.Millisecond)
dbCtx, _ := context.WithTimeout(handlerCtx, 150*time.Millisecond)
// DB 查询最多 150ms,整体请求最多 300ms
// 即使 handler 还有其他工作,DB 也会在 150ms 后放弃
result, err := queryDB(dbCtx)
传播规则
父子 context 之间的传播规则是 context 包的核心,这五条规则记牢,绝大部分问题都能想清楚。
规则 1:取消从父向子(单向)
root, rootCancel := context.WithCancel(context.Background())
child, childCancel := context.WithCancel(root)
grandchild, grandchildCancel := context.WithCancel(child)
rootCancel()
fmt.Println(root.Err(), child.Err(), grandchild.Err())
// canceled canceled canceled
父取消,所有子孙都被取消。反向不成立——子取消不会影响父。
规则 2:子取消不影响父
parent, _ := context.WithCancel(context.Background())
child, childCancel := context.WithCancel(parent)
childCancel()
fmt.Println(parent.Err()) // nil(未受影响)
fmt.Println(child.Err()) // canceled
规则 3:Deadline 取最早的
parent, _ := context.WithTimeout(context.Background(), 200*time.Millisecond)
child1, _ := context.WithTimeout(parent, 50*time.Millisecond) // 子更短 → 50ms
child2, _ := context.WithTimeout(parent, 500*time.Millisecond) // 父更短 → 200ms
子 context 的 deadline 永远不会晚于父。实际生效的总是更早的那个。这条规则保证子任务不可能超过父任务的生命周期。
规则 4:Context 树结构
一棵 context 树长这样:
Background
/ \
cancel-1 cancel-2
/ \ |
timeout value deadline
取消某个中间节点,它的所有子孙都被取消,但兄弟分支不受影响:
branch1, cancel1 := context.WithCancel(root)
branch2, _ := context.WithCancel(root)
leaf1, _ := context.WithTimeout(branch1, 1*time.Second)
leaf2 := context.WithValue(branch1, "key", "value")
leaf3, _ := context.WithDeadline(branch2, time.Now().Add(1*time.Second))
cancel1()
// branch1.Err() = canceled
// leaf1.Err() = canceled (branch1 的子孙)
// leaf2.Err() = canceled (branch1 的子孙)
// branch2.Err() = nil (兄弟分支,不受影响)
// leaf3.Err() = nil (branch2 的子孙,不受影响)
规则 5:取消和值可以自由组合
ctx := context.Background()
ctx = context.WithValue(ctx, "trace_id", "trace-001")
ctx, cancel := context.WithCancel(ctx)
defer cancel()
ctx = context.WithValue(ctx, "user_id", "user-123")
ctx2, cancel2 := context.WithTimeout(ctx, 1*time.Second)
defer cancel2()
// ctx2 既有所有值,也有取消,也有超时
fmt.Println(ctx2.Value("trace_id")) // trace-001
fmt.Println(ctx2.Value("user_id")) // user-123
值链和取消链是两条独立的链,但都从同一个父派生,互不冲突。
小结
核心机制只有四条:
select + ctx.Done()是 context 的标准用法,业务结果和取消信号二选一。- 错误类型
Canceled/DeadlineExceeded反映”实际取消原因”,用errors.Is判断。 - HTTP 框架自动接入:客户端断开 → context 取消,无需自己实现。
- 五条传播规则:取消父→子单向、子不影响父、deadline 取最早、树结构局部取消、值链与取消链独立。