Featured image of post Go 程序中 context 的职责与边界

Go 程序中 context 的职责与边界

context 是 Go 的『请求级数据通道』——传递截止时间、取消信号、链路标识。本文讲清它的真正定位与最佳实践

写在前面

每个 Go 函数签名里都见过它:

1
func (s *Service) GetUser(ctx context.Context, id int64) (*User, error)

ctx 是什么?做什么用的?很多新人对这个第一参数的认知止步于"传超时和取消信号"——但 context 在 Go 里的角色远不止于此。

它是 请求级别的数据通道 + 生命周期管理——理解清楚 context,是写出生产级 Go 服务的基础。

本文从设计动机到实战陷阱,把 context 讲透。


一、context 在解决什么问题

设想一个 Web 请求处理链路:

1
2
3
4
5
HTTP Handler → Service → Repository → DB Driver
              RPC Client → 上游 Service
              Cache

每一层都有自己的 IO,整个链路可能耗时几百 ms。如果在某一层之间发生:

  • 客户端关闭连接:HTTP handler 应该立刻停止处理,下游的 DB 查询也该取消
  • 整体超时:5 秒超时到了,每一层都该立刻返回
  • 传递请求级数据:traceId、userId、rpc deadline 怎么沿调用链传

每一层都自己写一套"超时控制 + 取消信号 + 数据传递"——重复劳动 + 容易漏。

Go 设计了 context 作为统一抽象——沿调用链向下游透明传递取消、截止时间、键值对


二、context 的接口

1
2
3
4
5
6
type Context interface {
    Deadline() (deadline time.Time, ok bool)
    Done() <-chan struct{}
    Err() error
    Value(key any) any
}

四个方法:

  • Deadline:何时超时(如果有)
  • Done:通道,关闭时表示"该停了"
  • Err:context 的终止原因(取消 / 超时)
  • Value:取请求级键值对

三、几种创建 context 的方式

1. 起点:Background / TODO

1
2
ctx := context.Background()  // 顶层,永不 cancel
ctx := context.TODO()        // 占位,意为"未来要补"

Background 是所有派生 context 的根——通常在 main、初始化代码、HTTP handler 入口创建。

2. WithCancel:手动取消

1
2
3
4
5
6
7
8
ctx, cancel := context.WithCancel(parent)
defer cancel()    // 必须!

go func() {
    if shouldStop() {
        cancel()    // 手动触发取消
    }
}()

派生出的 ctx 在 cancel() 调用后立刻关闭 Done channel

3. WithTimeout / WithDeadline

1
2
3
4
5
ctx, cancel := context.WithTimeout(parent, 5*time.Second)
defer cancel()

ctx, cancel := context.WithDeadline(parent, deadline)
defer cancel()

到时间自动取消。

4. WithValue:传请求级数据

1
2
3
4
5
6
7
type ctxKey string
const userIDKey ctxKey = "userID"

ctx := context.WithValue(parent, userIDKey, "u-12345")

// 下游
userID := ctx.Value(userIDKey).(string)

注意:key 必须是自定义类型——不能用 string,否则容易撞键。


四、context 的派生关系

context 是不可变的——每次 With 都返回一个新 context

派生关系是树形——parent 取消时所有 child 自动取消。这是 context 的核心性质。


五、典型用法

1. HTTP handler 透传

1
2
3
4
5
func handler(w http.ResponseWriter, r *http.Request) {
    ctx := r.Context()    // Go 自带:客户端断开时 ctx 自动 cancel
    user, err := userService.GetByID(ctx, id)
    // ...
}

r.Context() 是 Go 标准库给的 ——客户端断连时这个 ctx 自动 cancel。

2. 整体超时

1
2
3
4
5
ctx, cancel := context.WithTimeout(r.Context(), 3*time.Second)
defer cancel()

// 整个链路 3 秒超时,无论哪一层卡住都会触发
result, err := service.HeavyOp(ctx)

3. 传递 traceId

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
type ctxKey string
const traceIDKey ctxKey = "traceID"

func TraceMiddleware(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        traceID := r.Header.Get("X-Trace-Id")
        if traceID == "" { traceID = uuid.NewString() }
        ctx := context.WithValue(r.Context(), traceIDKey, traceID)
        next.ServeHTTP(w, r.WithContext(ctx))
    })
}

// 下游
traceID, _ := ctx.Value(traceIDKey).(string)
log.Printf("[trace=%s] querying user...", traceID)

4. 数据库查询

database/sql 自带 context 支持:

1
rows, err := db.QueryContext(ctx, "SELECT * FROM user WHERE id = ?", id)

context 取消时 query 立刻返回 context.Canceled 错误。

5. 监听 Done

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
func longRunning(ctx context.Context) error {
    for {
        select {
        case <-ctx.Done():
            return ctx.Err()
        case msg := <-ch:
            handle(msg)
        }
    }
}

任何长循环都该监听 ctx.Done——否则 context 取消你的代码不知道,照样跑


六、几个最常被踩的坑

1. 忘了 defer cancel

1
ctx, _ := context.WithTimeout(parent, 5*time.Second)   // ❌ 忽略 cancel

不调 cancel 会导致 context 资源泄漏——它会一直保留对父 context 的引用,直到 deadline 到。go vet 会警告。

正确:

1
2
ctx, cancel := context.WithTimeout(parent, 5*time.Second)
defer cancel()

2. context.Background() 用错地方

1
2
3
4
func someService() {
    ctx := context.Background()    // ❌ 应该接收上游 ctx
    db.QueryContext(ctx, ...)
}

Background 只在程序入口用。函数应该接收 ctx 参数,否则上游取消信号传不进来。

3. 用 string 作 Value 的 key

1
ctx := context.WithValue(parent, "userID", "u-123")    // ❌

string key 容易撞——尤其是其他库也用了 “userID”。永远用自定义类型。

4. 把 context 塞 struct 里

1
2
3
type Service struct {
    ctx context.Context    // ❌ 反模式
}

context 应该作为参数传递,不应该作为字段保存——否则 context 的"请求级"语义被破坏。

5. 用 context 传业务参数

1
2
3
ctx = context.WithValue(ctx, "orderID", "1234")
// 下游
orderID := ctx.Value("orderID").(string)

反模式——业务参数应该走函数参数,context 只传"基础设施数据"(traceID、userID、deadline)。

6. 不监听 Done

1
2
3
4
5
func work(ctx context.Context) {
    for i := 0; i < 1000000; i++ {
        // ... 不检查 ctx
    }
}

context 取消了你的循环还在跑——白白浪费资源。长循环必须 select 监听 ctx.Done


七、context 在 RPC 框架里

gRPC、Dubbo、Thrift 这些 RPC 框架都内置 context 支持——ctx 跨进程传递 deadline 和元数据

1
2
3
4
5
6
7
// 客户端
ctx := metadata.AppendToOutgoingContext(ctx, "x-trace-id", traceID)
resp, err := client.GetUser(ctx, req)

// 服务端
md, _ := metadata.FromIncomingContext(ctx)
traceID := md.Get("x-trace-id")[0]

ctx 让"分布式 trace"变成框架自动处理的事。


八、什么不该用 context 传

context 的定位是基础设施级数据 + 生命周期——不是万能的"请求级 store"。

✅ 适合 context 传:

  • traceID / requestID
  • userID(轻量身份标识)
  • tenantID
  • locale / language
  • deadline / cancellation

❌ 不该用 context 传:

  • 业务参数(如 OrderID)→ 函数参数
  • 大对象(如完整 User)→ 函数参数
  • 配置(如数据库连接)→ 通过依赖注入

如果 context 里塞太多东西——说明你把 context 当成『万能容器』用了,需要重新审视设计。


九、context 与 goroutine

启动 goroutine 时永远要传 ctx

1
2
3
4
5
6
7
8
go func(ctx context.Context) {
    select {
    case <-ctx.Done():
        return
    case msg := <-ch:
        process(msg)
    }
}(ctx)

不传 ctx 的 goroutine 是"野生 goroutine"——主流程结束它还在跑,资源泄漏 + 数据错乱。

errgroup 的 context

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
g, ctx := errgroup.WithContext(parent)

for _, task := range tasks {
    task := task
    g.Go(func() error {
        return process(ctx, task)    // 用 group 派生的 ctx
    })
}

if err := g.Wait(); err != nil {
    return err
}

任一 goroutine 失败时 errgroup 自动 cancel ctx——其他 goroutine 立刻退出。这是最优雅的"并发任务 + 全员协同退出"模式


十、什么时候需要把 ctx 写出来

新人写 Go 经常困惑——“什么时候要在函数签名里加 ctx?"。

经验法则

  1. 函数会做 IO 操作(DB / HTTP / RPC)→ 接收 ctx
  2. 函数会启 goroutine → 接收 ctx
  3. 函数链路长,可能被中途取消 → 接收 ctx
  4. 纯计算、纯转换、辅助函数 → 不需要
1
2
3
4
5
6
7
// ✓ 需要
func GetUser(ctx context.Context, id int64) (*User, error)
func Process(ctx context.Context, items []Item) error

// ✗ 不需要
func formatName(name string) string
func calculateDiscount(amount float64) float64

按"是否会 IO/并发/可能被中途打断"判断——一目了然。


小结

把全文压一句:

context 是 Go 的『请求级数据通道』——不只是 cancel 信号,而是『生命周期 + 元数据』的统一传递。

工程实战清单:

  1. 顶层用 Background,函数链路传 ctx
  2. WithTimeout 永远配 defer cancel
  3. value 的 key 用自定义类型
  4. 业务参数走函数参数,不要走 ctx
  5. 长循环 select 监听 Done
  6. goroutine 永远传 ctx,配 errgroup 更优雅
  7. ctx 不塞 struct,永远当参数传

把 context 用对,你的 Go 服务在"超时控制 + trace 跟踪 + 优雅退出"这三件事上几乎不会再出问题

使用 Hugo 构建
主题 StackJimmy 设计