context — 上下文传递与取消
context 是 Go 并发编程中使用最广、误解最深的包。它在 goroutine 之间传递:截止时间、取消信号和请求范围的值。
1. 四种创建方式
// 根 context(通常在整个程序入口使用)
ctx := context.Background() // 用于 main、init、测试
ctx := context.TODO() // 占位符:表示"还没想好用哪种 "
// 派生的 context
ctx, cancel := context.WithCancel(parent) // 手动取消
ctx, cancel := context.WithTimeout(parent, 5*time.Second) // 绝对超时
ctx, cancel := context.WithDeadline(parent, deadline) // 截止时间
ctx := context.WithValue(parent, key, value) // 携带值顶级陷阱:
WithCancel/WithTimeout/WithDeadline返回的cancel函数必须被调用——否则父 ctx 上会残留子节点引用,派生链一长就是实打实的内存/句柄泄漏。即使 ctx 已超时自动取消,调用 cancel 也是安全且推荐的(幂等)。始终defer cancel()。go vet的lostcancel检查专治此病。
1.1 Go 1.20/1.21 新增的四个派生函数
// 带原因的取消(Go 1.20)
ctx, cancel := context.WithCancelCause(parent)
cancel(errors.New("upstream 503")) // CancelCauseFunc(error)
ctx.Err() // 永远返回 context.Canceled(类型判断不受影响)
context.Cause(ctx) // 拿到真正的取消原因
// 超时也能带原因(Go 1.21)
ctx, cancel := context.WithTimeoutCause(ctx, 3*time.Second,
errors.New("client budget exceeded"))
// 取消后执行清理回调(Go 1.21)
stop := context.AfterFunc(ctx, func() { conn.Close() })
defer stop() // 返回 true = 成功阻止了 f 的运行
// 剥离取消信号,保留值(Go 1.21):用于"请求已结束,但后台任务要继续写日志/审计"
bg := context.WithoutCancel(reqCtx)
go func() { audit.Write(bg, rec) }()注意:早期提案里的
context.After(ctx)(返回 Done channel)没有进入发布版本——1.21 最终只提供AfterFunc,想拿 channel 就继续用ctx.Done()。网上部分文章把两者并列,属于以讹传讹。
各函数的适用边界:
WithCancelCause/Cause:解决"取消原因只能靠ctx.Err() == context.DeadlineExceeded猜"的问题。注意ctx.Err()的行为不变(仍是Canceled/DeadlineExceeded两个哨兵),原因只从context.Cause(ctx)取——这样下游errors.Is(err, context.Canceled)的既有代码不会被破坏。AfterFunc:回调在独立 goroutine 里执行;stop()返回 false 不代表 f 还没跑完,只是"没能阻止它启动"。适合关连接、释放在途计数这类清理,不适合做耗时工作。WithoutCancel:只保留 Value,屏蔽取消与超时——典型场景是 HTTP handler 返回后仍需完成的收尾任务(写审计日志、上报)。别滥用:它绕过了"整条链一起死"的契约,任务必须有自己独立的超时。
2. 取消传播机制
2.1 Parent-Child 取消链
parentCtx, cancel := context.WithCancel(context.Background())
defer cancel()
childCtx, _ := context.WithCancel(parentCtx)
// 取消 parent → child 也被取消
// 取消 child → parent 不受影响
cancel() // parent 和 child 都收到 Done() signal2.2 深入原理:ctx.Done() 为什么用"关闭 channel"
func worker(ctx context.Context) {
for {
select {
case <-ctx.Done(): // ← 返回一个只读 channel
fmt.Println("cancelled:", ctx.Err())
return
default:
doWork()
}
}
}ctx.Done()返回的 channel 永不发送数据,只是被关闭。- 关闭的 channel 立即可读(零值),所以所有阻塞在
<-ctx.Done()上的 goroutine 同时被唤醒。 ctx.Err()返回取消原因:context.Canceled(手动取消)或context.DeadlineExceeded(超时)。
2.3 正确使用 select
// 正确 — 把 ctx.Done() 放进 select
select {
case <-ctx.Done():
return ctx.Err()
case result := <-workCh:
return process(result)
}
// 错误 — 只等 time.After,不接上层的取消信号
select {
case <-time.After(timeout):
return ErrTimeout
case result := <-workCh:
return process(result)
}
// 问题:调用方 cancel 了 ctx,这个 goroutine 仍会傻等到超时才退出。
// 三个分支一起 select 才是完整写法(Done / workCh / 自己的超时)。3. Context 传递值
3.1 规范做法
// 1. 定义私有 key 类型(避免冲突)
type contextKey string // 或用 struct{} 更安全
const (
requestIDKey contextKey = "requestID"
userIDKey contextKey = "userID"
)
// 2. 封装存取函数
func WithRequestID(ctx context.Context, id string) context.Context {
return context.WithValue(ctx, requestIDKey, id)
}
func RequestID(ctx context.Context) string {
id, _ := ctx.Value(requestIDKey).(string)
return id
}陷阱:
- 不要用
string/裸内置类型当 key。不同包可能使用相同 key 造成互相覆盖,甚至被外部包恶意覆写。用未导出的自定义类型(type ctxKey struct{})做 key——WithValue比较的是 (类型, 值) 二元组,类型唯一即安全。- 不要用 Context 传业务参数。Context 只适合请求范围的元数据(request ID、trace ID、认证主体),把它当隐式参数袋会让函数签名失去真实性。
- key 一旦用了
any传 int/string 常量,就丢掉了类型隔离:ctx.Value("uid")与另一个包的ctx.Value("uid")会相撞。
3.2 Context 查找链
// WithValue 在父 ctx 基础上添加键值对(不可变设计)
ctx := context.Background()
ctx = context.WithValue(ctx, key1, "val1")
ctx = context.WithValue(ctx, key2, "val2")
// 查找链:ctx → key2(不匹配)→ 父 ctx → key1(匹配)
// 时间复杂度:O(depth),不建议嵌套太深4. 超时控制的经典模式
4.1 HTTP 请求超时
func fetchURL(ctx context.Context, url string) ([]byte, error) {
req, _ := http.NewRequestWithContext(ctx, "GET", url, nil)
resp, err := http.DefaultClient.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
return io.ReadAll(resp.Body)
}
// 调用方设置总体超时
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
data, err := fetchURL(ctx, "https://example.com")4.2 数据库查询超时
func queryUsers(ctx context.Context, db *sql.DB, name string) ([]User, error) {
// 为单个查询再设置更短的超时
queryCtx, cancel := context.WithTimeout(ctx, 500*time.Millisecond)
defer cancel()
return db.QueryContext(queryCtx, "SELECT * FROM users WHERE name = ?", name)
}4.3 串行操作 vs 并行操作
// 串行:每个操作独立超时
func serialWork(ctx context.Context) error {
// 操作 A: 最多 1s
ctxA, cancelA := context.WithTimeout(ctx, time.Second)
defer cancelA()
if err := doA(ctxA); err != nil {
return err
}
// 操作 B: 最多 2s
ctxB, cancelB := context.WithTimeout(ctx, 2*time.Second)
defer cancelB()
return doB(ctxB)
}
// 并行:同时启动,等任意完成或全部超时
func parallelWork(ctx context.Context) error {
ctx, cancel := context.WithCancel(ctx)
defer cancel()
ch := make(chan error, 2)
go func() { ch <- doA(ctx) }()
go func() { ch <- doB(ctx) }()
for i := 0; i < 2; i++ {
if err := <-ch; err != nil {
return err // ← 隐式触发 cancel(),另一个 goroutine 被取消
}
}
return nil
}5. Context 使用规范(官方推荐)
- 不要存储在结构体中。Context 应该作为函数第一个参数传递(通常是
ctx)。 - Context 参数总在第一位。
func DoSomething(ctx context.Context, arg string) error - 不要传 nil。如果不确定用什么,用
context.TODO()。 - 不要将 Context 用于传递可选参数。用 struct 或 options 模式。
ctx.Value仅用于请求范围的跨 API 边界数据。- 始终调用 cancel 函数。即使 ctx 已自动取消。
- Context 是并发安全的。可以在多个 goroutine 中同时使用。
6. 高级:自定义 Context 包装
大多数情况下应该用 WithCancel/WithTimeout/WithValue,而不是手写一个实现 context.Context 接口的类型。只有当你确实要"改写"取消/超时/值的某个维度时,才需要包装。
// 给 Context 套一个"缺省超时":父 ctx 没有 deadline 时补一个
type timeoutContext struct {
context.Context // 内联:未显式实现的方法全部转发给父 ctx
}
func (c *timeoutContext) Deadline() (time.Time, bool) {
if d, ok := c.Context.Deadline(); ok {
return d, true
}
return time.Now().Add(30 * time.Second), true
}
func WithDefaultTimeout(parent context.Context) context.Context {
return &timeoutContext{parent}
}关键:为什么用内联 context.Context。 Go 接口内联后,没显式实现的方法自动转发给内联值。Done/Err/Value 三个方法全部继承,取消信号和值查找才能穿透到父 ctx。如果"手动实现全部方法",漏转发任意一个都是灾难性 bug:
- 不转发
Done→ 这个 ctx 永远显示"未取消",上层cancel()/ 超时传不到它,下游select case <-ctx.Done()形同虚设 → goroutine 泄漏。 - 不转发
Err→ 看到 Done 关闭却拿不到错误,无法区分"取消还是超时"。 - 不转发
Value→ 整条链上的 request id / trace id / 认证主体全部丢失,日志断链、鉴权静默失败。 - 不转发
Deadline→ 下游以为没有截止时间,http.NewRequestWithContext之类不再生成超时,反而放大故障。
注意:
context.AfterFunc和Cause也依赖内建实现——它通过Value链找父cancelCtx注册回调。自定义类型若破坏Value转发,AfterFunc会退化为"起一个 goroutine 轮询 Done"(文档明确:未实现afterFuncer的自定义 ctx 走慢路径)。能内联就内联,别从零手搓四个方法。