Skip to content
Go
context — 上下文传递与取消

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() signal

2.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 使用规范(官方推荐)

  1. 不要存储在结构体中。Context 应该作为函数第一个参数传递(通常是 ctx)。
  2. Context 参数总在第一位。func DoSomething(ctx context.Context, arg string) error
  3. 不要传 nil。如果不确定用什么,用 context.TODO()。
  4. 不要将 Context 用于传递可选参数。用 struct 或 options 模式。
  5. ctx.Value 仅用于请求范围的跨 API 边界数据。
  6. 始终调用 cancel 函数。即使 ctx 已自动取消。
  7. 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 走慢路径)。能内联就内联,别从零手搓四个方法。