context 进阶与最佳实践
本教程共 80 篇 · 第 66 篇 · 更新于 2026-07-27 · 约 8 分钟阅读
66. context 进阶与最佳实践
本节目标:掌握 Go 1.21 context 新特性 AfterFunc 和 Cause,理解 context 传播规则,避开 Value 滥用等常见坑。
context 传播规则
上一节提过「父取消,子也取消」。更完整的规则:
- 父 context 取消,所有派生的子 context 立即取消
- 子 context 取消,不影响父 context
WithTimeout的子 context 也会派生自己的子,超时同样向下传播
Background(根,不可取消)
├── WithCancel A
│ ├── WithTimeout B(2s)
│ └── WithCancel C
└── WithTimeout D(5s)
└── WithCancel E
取消 A,B、C 都取消。取消 D,E 取消,但 A、B、C 不受影响。
传递 context 的习惯
Go 的惯例:需要 context 的函数,第一个参数是 ctx context.Context:
func DoSomething(ctx context.Context, arg string) error {
// ...
}
调用方传 ctx,被调方监听 ctx.Done() 或把 ctx 继续往下传。这样取消信号能从顶层一路传到底。
Note别把 context 存到结构体字段里长期持有。它应该是请求级别的,跟着函数调用链走。存到 struct 里会让生命周期失控。
AfterFunc(Go 1.21)
Go 1.21 新增 context.AfterFunc,注册一个回调函数,在 context 被取消时自动执行:
func main() {
ctx, cancel := context.WithCancel(context.Background())
stop := context.AfterFunc(ctx, func() {
fmt.Println("context 被取消了")
})
go func() {
time.Sleep(time.Second)
cancel()
}()
time.Sleep(2 * time.Second)
_ = stop // 保留引用(如需取消注册可调用 stop())
}
AfterFunc 返回一个 stop 函数,调用它可以取消注册(在 context 还没取消时):
stop := context.AfterFunc(ctx, func() {
fmt.Println("执行清理")
})
// 如果还没取消,取消注册
if stop() {
fmt.Println("成功取消注册")
}
Tip
AfterFunc适合「context 取消时要做点收尾」的场景,比如关闭资源、记录日志。比自己在 goroutine 里select监听Done()更简洁。回调在新的 goroutine 里执行。
取消原因 Cause(Go 1.21)
普通 cancel() 只产生 context.Canceled 错误,看不出为什么取消。Go 1.21 提供了带原因的版本:
ctx, cancel := context.WithCancelCause(context.Background())
go func() {
time.Sleep(time.Second)
cancel(errors.New("用户主动断开")) // 带原因的取消
}()
<-ctx.Done()
fmt.Println(context.Cause(ctx)) // 用户主动断开
WithCancelCause:创建可附带原因的 contextWithDeadlineCause/WithTimeoutCause:超时也可附原因context.Cause(ctx):获取取消原因(比Err()更具体)
如果没设原因,Cause 返回和 Err 一样的值。
WithoutCancel(Go 1.21)
有时你想在请求取消后还做点收尾,但不想被取消打断。WithoutCancel 返回一个不受父取消影响的 context:
func handler(ctx context.Context) {
// 请求处理(受 ctx 取消控制)
result := doWork(ctx)
// 写日志(即使请求取消也要写完)
logCtx := context.WithoutCancel(ctx)
writeLog(logCtx, result)
}
logCtx 继承了 ctx 的值(Value),但不继承取消信号。
Value 滥用问题
WithValue 用不好会变成隐式全局变量,几个常见坑:
1. 用字符串做 key 会冲突
ctx = context.WithValue(ctx, "id", 1)
ctx = context.WithValue(ctx, "id", 2) // 覆盖了!
正确做法:用自定义类型做 key:
type ctxKey int
const (
keyUserID ctxKey = iota
keyTraceID
)
ctx = context.WithValue(ctx, keyUserID, 42)
ctx = context.WithValue(ctx, keyTraceID, "abc-123")
2. 把业务参数塞进去
// 坏味道:用 context 传业务数据
ctx = context.WithValue(ctx, "pageSize", 20)
ctx = context.WithValue(ctx, "query", "golang")
search(ctx)
// 应该直接传参数
search(ctx, "golang", 20)
Warning
Value只适合传「请求级元数据」:trace ID、用户身份、认证信息、locale。业务参数老老实实用函数参数传,否则代码没法测试、没法追踪。
检查 context 是否取消
长时间运行的任务要定期检查 context,及时退出:
func process(ctx context.Context, items []Item) error {
for _, item := range items {
select {
case <-ctx.Done():
return ctx.Err() // 被取消,返回错误
default:
}
// 处理 item
if err := handle(item); err != nil {
return err
}
}
return nil
}
Tip循环开头检查
ctx.Done()
- 耗时 IO 操作传 ctx(
http.NewRequestWithContext、db.QueryContext等) - 别在循环里忘了检查,否则取消了还在空转
常见错误清单
| 错误 | 后果 |
|---|---|
忘了 defer cancel() | context 资源泄漏 |
| context 存到 struct 里 | 生命周期失控 |
| 用 string 做 Value key | key 冲突覆盖 |
| 用 Value 传业务参数 | 代码难以维护 |
不检查 ctx.Done() | 取消了还在跑 |
小结
- context 从父向子传播取消,子取消不影响父
- 函数第一个参数传
ctx context.Context,不要存 struct - Go 1.21
AfterFunc:取消时自动执行回调 - Go 1.21
WithCancelCause+Cause:带原因的取消 WithoutCancel:继承值但不继承取消- Value 只传请求级元数据,用自定义类型做 key
- 长任务循环里定期检查
ctx.Done()
下一部分进入模块和依赖管理,学怎么管理 Go 项目的代码和依赖。