Go context 使用规范:从入门到精通

Go 的 context 是并发编程里最容易被误解的包:有人只用它传值(当 map 用),有人到处 context.Background()(等于没用),有人嵌套几十层超时(不知道哪层生效)。这篇文章是我在团队推行的 context 使用规范。

一、context 的三种用途

1
2
3
4
ctx := context.Background()          // 根:所有 context 的起点(只在 main 用)
ctx := context.WithCancel(parent)    // 1. 取消:广播停止信号
ctx := context.WithTimeout(parent, 3*time.Second)  // 2. 超时:到点自动取消
ctx := context.WithValue(parent, key, val)  // 3. 传值:只传请求级数据(少用!)

最重要的一句话:context 的第一职责是控制生命周期(取消/超时),传值是顺手的功能,不是主要用途。

二、规范:什么时候用什么

入口:用 context.Background() 还是 context.TODO()

  • Background():main / 服务入口,确定没有更上层 context
  • TODO():代码还没接好 context 时的临时标记(会 code review 打回
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
// ✅ 服务入口
func main() {
    ctx := context.Background()
    server.Run(ctx)
}

// ❌ 反模式:函数内部凭空造 context
func handler() {
    ctx := context.Background()   // 上层一定有 ctx,为什么不传下来?
    db.Get(ctx, id)
}

函数签名:context 放第一参数

1
2
3
4
5
6
7
// ✅ 规范:ctx 永远是第一个参数
func GetUser(ctx context.Context, id int64) (*User, error)

// ❌ 反模式:context 藏在结构体里
type repo struct {
    ctx context.Context   // 存储 ctx = 并发不安全 + 生命周期错乱
}

context 属于调用链,不属于对象。存进 struct 是 Go 官方明确反对的。

传值:只传请求级数据

1
2
3
4
5
6
// ✅ 可以传:请求链路必须的东西
ctx = context.WithValue(ctx, traceIDKey{}, traceID)
ctx = context.WithValue(ctx, userIDKey{}, userID)

// ❌ 禁止传:业务参数(用显式参数)
ctx = context.WithValue(ctx, "city", "杭州")   // 该用参数 city

传值三条铁律

  1. 只传跨多层的请求级数据(traceID、userID、tenantID)
  2. key 用自定义类型,不用 string(防冲突)
  3. 读到的值要做类型断言 + 判空(别人可能不传)

三、超时嵌套:谁生效

1
2
3
4
5
6
7
8
9
// 外层 5s,内层 3s
ctx := context.WithTimeout(parent, 5*time.Second)
ctx2 := context.WithTimeout(ctx, 3*time.Second)
// ctx2 3s 到点 → 取消 → 连带取消 ctx(内层超时提前生效)

// 外层 3s,内层 5s
ctx := context.WithTimeout(parent, 3*time.Second)
ctx2 := context.WithTimeout(ctx, 5*time.Second)
// ctx2 实际还是 3s 就取消(继承父的截止时间)

规则有效的截止时间是所有嵌套里最早的那个。嵌套超时不是为了叠加,是为了"逐层收敛"。

实践:每层调用方设置自己的超时(调用下游前 WithTimeout),别依赖上游的——上游可能没传。

四、反模式清单(review 红线)

c c g o o o n n r t t o e e c u x x c t W t t t o x i i n . t n t E h e e r T s x r i t t ( m r . ) e u B o c a u c t c t o k n / g t r e o x u t n d ( ) g o r o u t i n e 退
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
// ❌ 最经典的泄漏:goroutine 用了父 ctx,但没人能取消它
func run() {
    ctx := context.Background()
    go doWork(ctx)   // 这个 goroutine 永远无法被取消
}

// ✅ 必须派生:给子 goroutine 独立取消能力
func run(ctx context.Context) {
    childCtx, cancel := context.WithCancel(ctx)
    defer cancel()
    go doWork(childCtx)
}

五、实践模板

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
// 服务间调用的标准姿势
func GetUserByID(ctx context.Context, id int64) (*User, error) {
    // 1. 本层超时(基于上游 ctx)
    ctx, cancel := context.WithTimeout(ctx, 2*time.Second)
    defer cancel()

    // 2. 传递 traceID
    ctx = WithTraceID(ctx, traceIDFrom(ctx))

    // 3. 调用下游(DB/HTTP/RPC),它们自己也会设超时
    return userRepo.Find(ctx, id)
}

总结

context 使用三句话:

  1. ctx 永远第一参数,永远往下传,不存对象、不建全局
  2. 超时是核心职责:每层设自己的超时,有效的就是最早那个
  3. 传值只传请求级:traceID/userID,用自定义 key,读到要断言

context 是调用链的"刹车"——没有它,一个慢下游能拖死整条链;用好它,每个环节都能按自己的节奏止损。