写在前面
每个 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:
flowchart TD
Bg[Background] --> Req[WithCancel]
Req --> A[WithTimeout 5s]
A --> A1[WithValue userID]
A --> A2[WithValue traceID]
Req --> B[WithCancel]派生关系是树形——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?"。
经验法则:
- 函数会做 IO 操作(DB / HTTP / RPC)→ 接收 ctx
- 函数会启 goroutine → 接收 ctx
- 函数链路长,可能被中途取消 → 接收 ctx
- 纯计算、纯转换、辅助函数 → 不需要
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 信号,而是『生命周期 + 元数据』的统一传递。
工程实战清单:
- 顶层用 Background,函数链路传 ctx
WithTimeout 永远配 defer cancel- value 的 key 用自定义类型
- 业务参数走函数参数,不要走 ctx
- 长循环 select 监听 Done
- goroutine 永远传 ctx,配 errgroup 更优雅
- ctx 不塞 struct,永远当参数传
把 context 用对,你的 Go 服务在"超时控制 + trace 跟踪 + 优雅退出"这三件事上几乎不会再出问题。