并发编程中,除了"互斥访问"和"等待通知"之外,还有一类核心需求——取消和超时控制。比如:HTTP 请求超时了,下游所有 goroutine 都应该停止工作;用户取消了操作,正在进行的数据库查询应该被中断。Go 标准库的 context 包就是为了解决这类问题而生的。

一、Context 解决什么问题?

假设一个 HTTP 请求触发了多个 goroutine 并行处理:

graph LR H[HTTP 请求] --> G1[goroutine: 查询数据库] H --> G2[goroutine: 调用下游 API] H --> G3[goroutine: 读取缓存]

如果请求超时了(比如客户端断开连接),这三个 goroutine 应该如何知道"不用干了"?

💀

没有 Context

请求超时后,数据库查询、下游 API、缓存读取还在继续跑,直到自然结束——资源被浪费,甚至可能拖垮整个服务。

有 Context

父 Context 取消后,ctx.Done() 立即触发,所有子 goroutine 收到信号,立即停止并释放资源

Context 提供了三个核心能力:

能力说明
取消信号传播父 Context 取消 → 所有子 Context 自动取消
超时/截止时间到期后自动取消
传递请求级数据trace ID、用户信息等跨 API 边界传递

二、Context 的接口

context.Context 是一个接口,只有四个方法:

方法作用
Deadline() (time.Time, bool)返回截止时间(如果设置了的话)
Done() <-chan struct{}返回一个 channel,Context 被取消时关闭
Err() errorDone 关闭后,返回取消原因:context.Canceledcontext.DeadlineExceeded
Value(key any) any获取与 key 关联的值

三、创建 Context 的四种方式

// 1. 根 Context(不可取消,通常作为起点)
ctx := context.Background()  // 主函数、初始化、测试
ctx := context.TODO()        // 不确定用哪个时的占位符

// 2. 可取消的 Context
ctx, cancel := context.WithCancel(parentCtx)
defer cancel() // ✅ 必须调用,释放资源

// 3. 带超时的 Context
ctx, cancel := context.WithTimeout(parentCtx, 5*time.Second)
defer cancel()

// 4. 带截止时间的 Context
deadline := time.Now().Add(5 * time.Second)
ctx, cancel := context.WithDeadline(parentCtx, deadline)
defer cancel()

它们形成一棵树状结构:

graph TD BG[context.Background] --> C1[WithCancel → ctx1] BG --> C4[WithTimeout 10s → ctx4] C1 --> C2[WithTimeout 3s → ctx2] C1 --> C3[WithValue traceID → ctx3] C4 --> C5[WithCancel → ctx5]
  • 取消 ctx1ctx2ctx3 也被取消
  • 取消 ctx4ctx5 也被取消
  • 取消 ctx5ctx4 不受影响(子不影响父)

关键规则:父 Context 取消,所有子 Context 自动取消。子 Context 取消,不影响父 Context。

四、基本用法

超时控制

func fetchData(ctx context.Context) (string, error) {
    // 模拟一个耗时操作
    ch := make(chan string, 1)
    go func() {
        time.Sleep(3 * time.Second) // 模拟耗时
        ch <- "data"
    }()

    select {
    case data := <-ch:
        return data, nil
    case <-ctx.Done():
        return "", ctx.Err() // 超时或被取消
    }
}

func main() {
    // 设置 1 秒超时
    ctx, cancel := context.WithTimeout(context.Background(), time.Second)
    defer cancel()

    data, err := fetchData(ctx)
    if err != nil {
        fmt.Println("失败:", err) // context deadline exceeded
        return
    }
    fmt.Println("成功:", data)
}

取消传播

func main() {
    ctx, cancel := context.WithCancel(context.Background())

    // 启动多个 worker
    for i := 0; i < 5; i++ {
        go worker(ctx, i)
    }

    time.Sleep(2 * time.Second)
    cancel() // 通知所有 worker 停止
    time.Sleep(time.Second) // 等待 worker 退出
}

func worker(ctx context.Context, id int) {
    for {
        select {
        case <-ctx.Done():
            fmt.Printf("Worker %d: 收到取消信号,退出\n", id)
            return
        default:
            fmt.Printf("Worker %d: 工作中...\n", id)
            time.Sleep(500 * time.Millisecond)
        }
    }
}

传递请求级数据

type contextKey string

const traceIDKey contextKey = "traceID"

func withTraceID(ctx context.Context, traceID string) context.Context {
    return context.WithValue(ctx, traceIDKey, traceID)
}

func getTraceID(ctx context.Context) string {
    if v, ok := ctx.Value(traceIDKey).(string); ok {
        return v
    }
    return "unknown"
}

func handleRequest(ctx context.Context) {
    traceID := getTraceID(ctx)
    fmt.Printf("[%s] 处理请求\n", traceID)
    queryDB(ctx)
}

func queryDB(ctx context.Context) {
    traceID := getTraceID(ctx)
    fmt.Printf("[%s] 查询数据库\n", traceID)
}

五、Context 的传递规范

Go 官方对 Context 的使用有明确的规范:

  1. Context 作为函数的第一个参数,命名为 ctx func DoSomething(ctx context.Context, arg string) error
  2. 不要把 Context 放到 struct 中(Go 1.7 的建议;某些长生命周期对象可以例外)
  3. 不要传递 nil Context —— 不确定用什么就用 context.TODO()
  4. WithValue 只传递请求级的数据(trace ID、请求 ID、认证 token),不要传业务参数
  5. 同一个 Context 可以传给多个 goroutine —— Context 是并发安全的

六、实现原理

cancelCtx

WithCancel 返回的核心类型是 cancelCtx,关键字段与行为:

字段作用
Context父 Context
done chan struct{}Done() 返回的 channel
children map[canceler]struct{}子 Context 列表
err error取消原因

cancel() 的执行:关闭 done channel → 遍历 children 逐个取消 → 从父 Context 中移除自己。

timerCtx

WithTimeoutWithDeadline 返回的是 timerCtx,它在 cancelCtx 基础上加了定时器:内嵌 cancelCtx,加一个 *time.Timerdeadline time.Time。到期时 timer 触发 → 调用 cancelCtx.cancel() → 关闭 done channel → 取消所有子 Context。

valueCtx

WithValue 返回的是 valueCtx,它的查找是链式的:

ctx3 := WithValue(ctx2, "k3", "v3")
ctx2 := WithValue(ctx1, "k2", "v2")
ctx1 := WithValue(bg, "k1", "v1")
graph LR Q["ctx3.Value('k1')"] --> C3[ctx3: k3=v3 不匹配] C3 --> C2[ctx2: k2=v2 不匹配] C2 --> C1[ctx1: k1=v1 匹配 ✅] C1 --> R[返回 v1]

注意:Value 的查找是 O(n) 的,n 是 WithValue 的嵌套层数。不要存大量数据,也不要在性能敏感路径上频繁调用。

七、使用 Context 最常踩的 4 个坑

坑 1:忘记调用 cancel

WithCancel/WithTimeout/WithDeadline 返回的 cancel 函数 必须调用,否则会导致资源泄漏:

// ❌ 忘记调用 cancel,导致 goroutine 和 timer 泄漏
func handle() {
    ctx, _ := context.WithTimeout(context.Background(), 5*time.Second)
    // cancel 被丢弃了 💀
    doWork(ctx)
}
// ✅ 始终 defer cancel
func handle() {
    ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
    defer cancel() // 即使操作提前完成,也释放 timer 资源
    doWork(ctx)
}

小技巧:即使 Context 会因超时自动取消,也应该 defer cancel()。因为 cancel 会立即释放内部 timer 和子 Context 的关联,不必等到超时。

坑 2:WithValue 的 key 用了内置类型

// ❌ 用 string 做 key,可能和其他包冲突
ctx = context.WithValue(ctx, "userID", 123)
// ✅ 用自定义未导出类型做 key,避免冲突
type contextKey string
const userIDKey contextKey = "userID"
ctx = context.WithValue(ctx, userIDKey, 123)

坑 3:把业务参数塞进 Context

// ❌ 把业务参数放进 Context
ctx = context.WithValue(ctx, "orderID", orderID)
processOrder(ctx) // 函数签名看不出需要 orderID

// ✅ 业务参数用函数参数传递
processOrder(ctx, orderID) // 依赖关系一目了然

WithValue 只适合传递请求级的横切关注点(trace ID、认证信息、请求 ID),不适合传递业务数据。

坑 4:不检查 ctx.Done()

启动的 goroutine 如果不检查 ctx.Done(),取消信号就传不进去:

// ❌ 不检查 Done,cancel 调了也没用
func doWork(ctx context.Context) {
    for i := 0; i < 1000000; i++ {
        heavyComputation(i) // 死干,不看取消信号
    }
}
// ✅ 在循环中定期检查
func doWork(ctx context.Context) {
    for i := 0; i < 1000000; i++ {
        select {
        case <-ctx.Done():
            return // 收到取消信号,退出
        default:
        }
        heavyComputation(i)
    }
}

八、Go 1.21+ 新增 API

Go 1.21 新增了几个实用的 Context 函数:

// WithoutCancel:创建一个不会被父 Context 取消的子 Context
// 适合在父请求结束后还需要继续的后台任务
ctx := context.WithoutCancel(parentCtx)

// AfterFunc:Context 取消时执行回调
stop := context.AfterFunc(ctx, func() {
    fmt.Println("ctx 被取消了,执行清理")
})
// stop() 可以阻止回调执行
// WithCancelCause(Go 1.20+):取消时附带原因
ctx, cancel := context.WithCancelCause(parentCtx)
cancel(fmt.Errorf("用户主动取消")) // 附带原因

// 获取取消原因
err := context.Cause(ctx) // "用户主动取消"

九、实战建议

  1. 所有外部调用都传 Context——HTTP 请求、数据库查询、RPC 调用,都应该接受 Context
  2. 始终 defer cancel()——防止资源泄漏,即使 Context 会自动超时
  3. WithValue 只传请求级数据——trace ID、认证信息,不传业务参数
  4. 长时间操作要检查 ctx.Done()——否则取消信号传不进去
  5. 超时要合理设置——下游超时应该小于上游超时,留出处理和返回的时间
// 超时层级示例
// HTTP handler: 10s
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
defer cancel()

// 数据库查询: 3s(小于 handler 的 10s)
dbCtx, dbCancel := context.WithTimeout(ctx, 3*time.Second)
defer dbCancel()
# 检查 Context 泄漏
go vet ./...
go test -race ./...

Context 是 Go 并发编程的基础设施——它不是一个同步原语,而是一个 信号传播机制。理解它的树状取消传播和 Value 的链式查找,遵循官方的使用规范,你就能在并发程序中优雅地处理超时和取消。