从「它到底是什么」到「源码怎么实现」——讲清 Context 的功能、解决什么问题、四种构造方式的用法差异,以及生产环境的最佳实践与常见陷阱。
一句话:Context 是「一次请求的生命周期 + 取消信号 + 请求域数据」的载体,沿着调用链一路往下传
Context(上下文)是 Go 标准库 context 包提供的一个接口,用于在 goroutine 之间(尤其是跨 API 边界、跨服务调用)传递取消信号、截止时间、超时控制与请求域数据。它本身不做什么事,而是一个「约定」——所有可能阻塞的函数都接收它,并在它被取消时主动退出。
核心类比:把一次请求想象成一棵树。用户发来一个 HTTP 请求,服务端可能要查数据库、调三个微服务、读缓存。这些操作都是这棵树的子节点。如果用户中途关闭了浏览器,或者某个操作超时了,我们希望整棵树上的所有工作都立即停止,而不是让那些 goroutine 傻傻地等到自己完成、白白占用资源。Context 就是用来「广播这个停止信号」的机制。
Context 不是用来控制 goroutine 的(Go 没有强制杀死 goroutine 的机制),而是一个协作式的通知机制:Context 只负责「喊一声停」,具体停不停、怎么停,由拿到 Context 的那个函数自己决定、自己实现。这就是为什么所有阻塞操作都要用 select 监听 ctx.Done()。
type Context interface { Deadline() (deadline time.Time, ok bool) Done() <-chan struct{} Err() error Value(key interface{}) interface{} }
只有 4 个方法,且没有 Cancel 方法——取消能力由可取消的具体实现(cancelCtx)提供,通过 WithCancel 返回的 cancel 函数来触发。这个设计保证了「只有创建者能取消,传递者只能监听」,避免下游随意取消上游。
在 Context 出现之前(Go 1.7 之前),这些问题每一个都很棘手
启动一个 goroutine 去做耗时操作,调用方已经不需要结果了(用户取消、超时、报错提前返回),但这个 goroutine 还在跑,永远不退出,占着内存和栈。服务跑几天后 goroutine 数量爆炸,OOM。
一个请求会派生出一棵调用树(DB 查询、RPC 调用、子 goroutine)。没有统一机制时,要停止整棵树得给每个环节单独发信号,代码里到处是 quit chan,极易漏掉某一个。
每个函数自己实现超时逻辑,用 time.After 或 time.Sleep,导致嵌套调用时超时时间叠加(外层 3s、内层各 3s,实际可能等 9s),而且无法做到「总超时」。
traceID、用户 ID、语言偏好这类「贯穿整条调用链」的数据,如果靠函数参数传递,会污染所有函数签名;如果用全局变量,并发下会串号。
// ❌ 泄漏版:调用方超时返回后,这个 goroutine 还在跑,结果永远没人接收 func fetch() Result { ch := make(chan Result) go func() { r := slowOperation() // 耗时 10s ch <- r // 没人接收 → 永久阻塞 → goroutine 泄漏 }() select { case r := <-ch: return r case <-time.After(2 * time.Second): return Result{} // 超时返回了,但 goroutine 还在跑 } }
每次调用泄漏一个 goroutine。QPS 100 的服务,一小时泄漏 36 万个 goroutine。
// ✅ 正确版:ctx 被取消时,子 goroutine 能感知并退出 func fetch(ctx context.Context) Result { ch := make(chan Result, 1) // ← 关键:缓冲区 1,即使没人收也不会阻塞 go func() { ch <- slowOperation() }() select { case r := <-ch: return r case <-ctx.Done(): // ← 监听取消信号 return Result{Err: ctx.Err()} } }
两个关键点:channel 缓冲为 1(防 goroutine 卡在发送)+ select 监听 ctx.Done()(及时退出)。
| 问题 | Context 之前 | Context 之后 |
|---|---|---|
| goroutine 泄漏 | 靠人工保证,极易遗漏 | Done() 统一通知,机制化解决 |
| 取消传播 | 每个函数加 quit chan 参数,签名混乱 | 一个 ctx 参数贯穿全链路,自动级联 |
| 超时控制 | 各自 time.After,超时叠加 | WithTimeout 统一截止时间,子级自动继承 |
| 请求域数据 | 全局变量(并发串号)或污染函数签名 | WithValue 随 ctx 传递,天然按请求隔离 |
| 跨库协作 | 各库各自定义,无法互通 | 标准库统一约定,DB/HTTP/gRPC 全部支持 |
搞懂这四个方法,就搞懂了 Context 的全部对外能力
| 方法 | 作用 | 返回值含义 | 典型用途 |
|---|---|---|---|
Done() |
返回一个 channel,当 Context 被取消或超时时,这个 channel 会被关闭 | <-chan struct{}。关闭而非发送值——这样所有监听者都能同时收到(广播) |
select 中监听,实现「可被取消的阻塞」 |
Err() |
返回 Context 为什么结束 | nil=还没结束;context.Canceled=被主动取消;context.DeadlineExceeded=超时 |
区分「用户取消」与「超时」,做不同处理 |
Deadline() |
返回该 Context 的截止时间 | (time.Time, bool),第二个返回值为 false 表示没设截止时间 |
设置底层 I/O 的 deadline;计算剩余时间 |
Value() |
取出该 Context 上绑定的请求域数据 | interface{},key 不存在返回 nil |
取 traceID、用户身份等贯穿链路的数据 |
这是 Context 最核心的方法。它返回一个只读 channel。重点在于:当取消发生时,这个 channel 是被 close 掉的,而不是往里面发一个值。为什么?因为 close 一个 channel 会让所有正在 <-ch 上阻塞的 goroutine 同时被唤醒——这是一次性广播,正是取消语义需要的。如果发值,只有一个接收者能收到。
// 标准用法:select 监听 Done select { case result := <-resultCh: // 正常拿到结果 handle(result) case <-ctx.Done(): // 被取消或超时,立即退出 return ctx.Err() } // 如果没设取消,Done() 返回 nil,此时这个 case 永远阻塞(相当于禁用该分支) var bg = context.Background() bg.Done() // → nil(nil channel 上的接收操作永远阻塞)
context.Background() 和 context.TODO() 的 Done() 返回 nil。在 select 里监听 nil channel 会永久阻塞(这是 Go 的特性),所以「永不取消」就自然实现了,不需要特判。
只有在 Done() 关闭后,Err() 才返回非 nil。两个标准错误值:
context.Canceled —— 被主动调用 cancel() 取消context.DeadlineExceeded —— 截止时间到了// 区分取消原因,做不同处理 if err := ctx.Err(); err != nil { switch err { case context.Canceled: log.Println("用户主动取消,无需告警") // 客户端断开,正常现象 case context.DeadlineExceeded: log.Println("处理超时,需要告警") // 系统慢了,要关注 } }
返回绝对时间(不是剩余时长)。第二个返回值表示「是否设置了 deadline」。用途:把 Context 的超时传递给底层 I/O(如 net.Conn.SetDeadline),避免上层超时了底层还在傻等。
if dl, ok := ctx.Deadline(); ok { // 有截止时间:把它设到底层连接上 conn.SetDeadline(dl) log.Printf("剩余时间: %v", time.Until(dl)) } else { // 无截止时间:Background/TODO 就是这种情况 conn.SetDeadline(time.Time{}) }
取数据。只能用于「请求域」数据(随请求生命周期存在、跨函数边界传递的元数据),不能当万能参数袋用。详见第 07 节。
traceID := ctx.Value("traceID") // ⚠️ 不建议用字符串做 key,会冲突 traceID := ctx.Value(traceKey{}) // ✅ 用自定义类型做 key,类型安全
Background/TODO 是根,WithCancel/WithTimeout/WithValue 是派生——它们构成一棵树
| 函数 | 用途 | 是否需 cancel | 典型场景 |
|---|---|---|---|
Background() | 根 Context,永不取消、无值、无 deadline | 否 | main 函数、初始化、测试代码的起点 |
TODO() | 语义同 Background,但表示「还不确定该用哪个」 | 否 | 重构过渡期,提醒后续补上(静态检查可告警) |
WithCancel(parent) | 派生一个可手动取消的子 Context | 是 | 手动控制生命周期,如启动/停止后台任务 |
WithTimeout(parent, d) | 派生一个 d 时间后自动取消的子 Context | 是 | 网络请求、RPC 调用、任何「最多等 N 秒」 |
WithDeadline(parent, t) | 派生一个到 t 时刻自动取消的子 Context | 是 | 有明确绝对截止时间;或承接上游传来的 deadline |
WithValue(parent, k, v) | 派生一个携带 k-v 的子 Context | 否 | traceID、用户身份等请求域元数据 |
WithCancel / WithTimeout / WithDeadline 都会返回 cancel 函数。必须调用它,否则子 Context 会一直挂在父节点上,其关联的 goroutine 和定时器直到父 Context 取消时才释放——这就是内存泄漏。标准写法是 defer cancel()。
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) defer cancel() // ← 必须!即使超时已到,调用 cancel 也是无害且推荐的
// 已经确定要用根 ctx,却写 TODO ctx := context.TODO() // 静态检查工具会告警,且语义不清
// 明确的起点用 Background ctx := context.Background() // 不确定/待重构处用 TODO 做标记 ctx := context.TODO() // TODO: 接入请求 ctx
两者功能完全相同(都是空的 root ctx),区别只在语义和意图:TODO 是给人和工具看的「这里还没想好」,便于后续检索补齐。
父取消 → 子全部取消;子取消 → 父不受影响。理解这一点就理解了 Context 的一半
package main import ( "context" "fmt" "time" ) func main() { parent, parentCancel := context.WithCancel(context.Background()) defer parentCancel() child, childCancel := context.WithCancel(parent) childCancel() // 只取消子 Context fmt.Println("child err:", child.Err()) // context.Canceled fmt.Println("parent err:", parent.Err()) // nil ← 父不受影响! // 反过来:取消父,子会跟着取消 parent2, parent2Cancel := context.WithCancel(context.Background()) child2, _ := context.WithCancel(parent2) parent2Cancel() fmt.Println("child2 err:", child2.Err()) // context.Canceled ← 被父级联取消 } // 输出: // child err: context.Canceled // parent err: <nil> // child2 err: context.Canceled
因为「子取消不影响父」,我们可以给某个具体的子调用单独加更短的超时,而不用担心它会连带取消整条链路。这是精细化超时控制的基础。
// 整体请求 5s,但查缓存最多只允许 200ms cacheCtx, cancel := context.WithTimeout(ctx, 200*time.Millisecond) defer cancel() if v, err := cache.Get(cacheCtx, key); err == nil { return v // 命中缓存 } // 缓存超时/未命中 → 继续走 DB,此时 ctx(父)依旧有效,没有被取消 return db.Query(ctx, sql)
两者本质相同,只是一个给「多久之后」,一个给「什么时候」
最常用。传一个 time.Duration,表示「从现在起多久后超时」。
ctx, cancel := context.WithTimeout(parent, 3*time.Second) defer cancel()
内部实现就是 WithDeadline(parent, time.Now().Add(3s))。
传一个 time.Time,表示「到这个时刻就超时」。
deadline := time.Now().Add(3 * time.Second) ctx, cancel := context.WithDeadline(parent, deadline) defer cancel()
适合承接上游传来的 deadline,或跨多次调用共享同一截止时间。
如果父 Context 的 deadline 是 3s 后,而你在子 Context 上设 10s,那么实际生效的是 3s——父先到就会级联取消子。Deadline() 返回的是继承后的最早时间,不是你自己设的那个。
parent, _ := context.WithTimeout(context.Background(), 3*time.Second) child, _ := context.WithTimeout(parent, 10*time.Second) // 设了 10s dl, _ := child.Deadline() fmt.Println(time.Until(dl)) // 约 3s,不是 10s!被父截断了
这是设计如此而非 bug:保证「总耗时不会超过外层的预算」。子超时只能比父更严格,不能更宽松。
func fetchWithTimeout(url string) ([]byte, error) { ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) defer cancel() req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) if err != nil { return nil, err } resp, err := http.DefaultClient.Do(req) if err != nil { // 超时时 err 会包装 context.DeadlineExceeded if errors.Is(err, context.DeadlineExceeded) { return nil, fmt.Errorf("请求 %s 超时: %w", url, err) } return nil, err } defer resp.Body.Close() return io.ReadAll(resp.Body) }
网络库通常会把 context.DeadlineExceeded 包装一层(*url.Error),直接 == 比较会失败。用 errors.Is(err, context.DeadlineExceeded) 才能穿透包装正确识别。
// 每个函数自己 sleep/after func A() { B(); C() } func B() { wait(3*time.Second) } func C() { wait(3*time.Second) } // 实际最坏 6s,且无法整体控制
func A(ctx context.Context) { ctx, cancel := context.WithTimeout(ctx, 4*time.Second) defer cancel() B(ctx); C(ctx) } // B、C 共享同一个 deadline,总耗时 ≤ 4s // B 用完 3s,C 就只剩 1s
这是最容易被滥用的 API —— 用对了很优雅,用错了是灾难
Context 的 Value 只应用于「请求域」的、跨进程/跨 API 边界传递的元数据,且是「可选的」—— 即:即使取不到,程序也应该能正常工作(降级),而不是直接报错。函数的必需参数永远应该显式声明在函数签名里,不要塞进 Context。
// 把业务参数塞进 ctx ctx = context.WithValue(ctx, "userID", 123) ctx = context.WithValue(ctx, "pageSize", 20) ctx = context.WithValue(ctx, "sortBy", "time") func ListOrders(ctx context.Context) { // 必须靠猜才知道要传什么,编译期无检查 size := ctx.Value("pageSize").(int) // 可能 panic }
// 只放贯穿链路的元数据 ctx = context.WithValue(ctx, traceKey{}, "abc-123") ctx = context.WithValue(ctx, userKey{}, "u-88") // 业务参数显式声明 func ListOrders(ctx context.Context, pageSize int, sortBy string) { // 签名即文档,编译期检查 }
用字符串做 key 会导致不同包之间的 key 冲突(都叫 "userID" 但含义不同)。标准做法:定义一个私有的、不可导出的类型作为 key。
// ✅ 推荐:定义不可导出的 key 类型 + 配套读写函数 package mypkg type traceIDKey struct{} // 空结构体,零内存分配,且类型唯一 type userKey struct{} // 写入 func WithTraceID(ctx context.Context, id string) context.Context { return context.WithValue(ctx, traceIDKey{}, id) } // 读取(类型安全,带 ok 判断) func TraceIDFrom(ctx context.Context) (string, bool) { id, ok := ctx.Value(traceIDKey{}).(string) return id, ok } // 使用方 ctx := WithTraceID(parent, "req-abc-123") if id, ok := TraceIDFrom(ctx); ok { log.Println("trace:", id) }
ctx.Value(k).(string) 在 key 不存在或类型不符时会 panic。永远用 v, ok := ctx.Value(k).(T) 的双返回值形式。
Value() 会先查自己,查不到就沿父链一路向上找,直到根节点。如果都没有,返回 nil。注意:不会向下查——父 Context 取不到子 Context 上设的值。
root := context.WithValue(context.Background(), k1, "v1") child := context.WithValue(root, k2, "v2") child.Value(k2) // "v2" 自己就有 child.Value(k1) // "v1" 向上找到父 root.Value(k2) // nil 不会向下找!
因为要沿链向上查找,Value 的查找是 O(链深) 的,不是 O(1)。虽然 context 链通常很短(几层),但不要在热循环里频繁调用 Value——在循环外取一次存到局部变量。
traceID, _ := TraceIDFrom(ctx) // ✅ 循环外取一次 for _, item := range items { log(traceID, item) }
| 适合放 Value 的 | 不适合放 Value 的 |
|---|---|
| traceID / requestID / spanID | 函数的必需业务参数(userID 若业务必需则应显式传) |
| 用户身份(认证后的 principal) | 可选的配置项(应走 options 模式或配置结构体) |
| 客户端语言 / 时区 / 地区偏好 | 数据库连接、客户端实例(应走依赖注入) |
| 请求的 Accept-Encoding 等元数据 | 可变状态(Context 应当是只读的) |
| 日志所需的公共字段 | 大对象(会一直挂在链上无法释放) |
理解四种 Context 的具体实现,很多"为什么"就自然明白了
| 类型 | 由谁创建 | 核心字段 | 取消能力 |
|---|---|---|---|
emptyCtx | Background() / TODO() | 无(只是个 int 占位) | 永不取消,Done() 返回 nil |
cancelCtx | WithCancel() | done chan、children map、err | 可取消,是其他两者的基础 |
timerCtx | WithTimeout/WithDeadline | 内嵌 cancelCtx + timer、deadline | 在 cancelCtx 基础上加定时器 |
valueCtx | WithValue() | key、val、parent | 无自己的取消,继承父 |
// 简化版源码(Go 1.21+ 改用了 canceler 链表,但原理一致) type cancelCtx struct { Context // 内嵌父 Context mu sync.Mutex done chan struct{} // 懒创建,第一次 Done() 时才 make children map[canceler]struct{} // ← 所有可取消的子节点 err error // 取消原因 } func (c *cancelCtx) Done() <-chan struct{} { c.mu.Lock() if c.done == nil { c.done = make(chan struct{}) } d := c.done c.mu.Unlock() return d } func (c *cancelCtx) cancel(removeFromParent bool, err error) { c.mu.Lock() if c.err != nil { // 幂等:已取消过就直接返回 c.mu.Unlock() return } c.err = err // ③ 设置错误 if c.done == nil { c.done = closedchan // 复用一个已关闭的 channel } else { close(c.done) // ② 关闭 channel → 广播给所有监听者 } for child := range c.children { // ④ 递归取消所有子节点 child.cancel(false, err) } c.children = nil if removeFromParent { // ⑤ 从父节点摘除自己 removeChild(c.Context, c) } c.mu.Unlock() }
c.err != nil 判断,重复调用不会 panic。所以 defer cancel() 即使超时已触发也绝对安全。Done() 时才 make(chan),没被监听的 Context 零开销。创建子 Context 时,Go 会调用 propagateCancel 决定「如何让自己能被父级取消」。这是理解 Context 树的关键函数:
func propagateCancel(parent Context, child canceler) { done := parent.Done() if done == nil { return // ① 父不可取消(Background/TODO),无需挂靠 } select { case <-done: // ② 父已经被取消了,立即取消自己 child.cancel(false, parent.Err()) return default: } // ③ 找到父链上最近的 *cancelCtx,把自己加入它的 children if p, ok := parentCancelCtx(parent); ok { p.mu.Lock() if p.err != nil { child.cancel(false, p.err) // 检查竞态:父可能刚好被取消 } else { if p.children == nil { p.children = make(map[canceler]struct{}) } p.children[child] = struct{} } p.mu.Unlock() return } // ④ 父是自定义 Context 实现:起一个 goroutine 转发取消信号 go func() { select { case <-parent.Done(): child.cancel(false, parent.Err()) case <-child.Done(): } }() }
如果你自定义了 Context 实现(不内嵌 *cancelCtx),Go 无法直接把它挂进 children map,只能额外起一个 goroutine 来转发取消信号。这也是官方不建议自己实现 Context 的原因之一——优先用标准库提供的。
type timerCtx struct { *cancelCtx // 内嵌,复用取消能力 timer *time.Timer // 到点触发的定时器 deadline time.Time // 绝对截止时间 } func (c *timerCtx) cancel(removeFromParent bool, err error) { c.cancelCtx.cancel(false, err) // 复用 cancelCtx 的取消逻辑 if removeFromParent { removeChild(c.cancelCtx.Context, c) } c.mu.Lock() if c.timer != nil { c.timer.Stop() // ← 停掉定时器,防止泄漏 c.timer = nil } c.mu.Unlock() }
即使超时已经触发(timer 已经 fire 过),底层的 timer 对象和父节点的 children 引用仍需清理。只有调用 cancel() 才会 timer.Stop() 并 removeChild。不调用的话,在父 Context 生命周期结束前,这些资源都不释放——在长生命周期的父 Context 上反复创建短超时子 Context,就会累积泄漏。
生产代码里最常见的六种 Context 用法
func handler(w http.ResponseWriter, r *http.Request) { ctx := r.Context() // ← 每个请求都有,客户端断开时自动取消 // 客户端断开连接时,ctx 会被取消 select { case <-time.After(5 * time.Second): fmt.Fprintln(w, "处理完成") case <-ctx.Done(): log.Println("客户端断开,停止处理:", ctx.Err()) return } } // 服务端也可以统一设置超时(用中间件) func timeoutMiddleware(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second) defer cancel() next.ServeHTTP(w, r.WithContext(ctx)) }) }
// database/sql 的 *Context 系列方法都会接收 ctx ctx, cancel := context.WithTimeout(r.Context(), 3*time.Second) defer cancel() var user User err := db.QueryRowContext(ctx, "SELECT id, name FROM users WHERE id = ?", userID).Scan(&user.ID, &user.Name) if err != nil { if errors.Is(err, context.DeadlineExceeded) { // 查询超时:连接会被自动归还,已执行的语句在服务端被 KILL return fmt.Errorf("查询用户超时") } return err }
QueryRowContext 在 ctx 取消时会:① 立即返回,不再等待;② 把连接标记为需要清理并归还连接池。用不带 Context 的 QueryRow 则会一直阻塞到数据库返回,请求已经超时了但连接还被占着,高并发下连接池迅速耗尽。
func callWithRetry(ctx context.Context, url string, maxRetry int) ([]byte, error) { var lastErr error for i := 0; i < maxRetry; i++ { // 每次重试单独 2s 超时,但都受外层 ctx 总预算约束 attemptCtx, cancel := context.WithTimeout(ctx, 2*time.Second) data, err := doRequest(attemptCtx, url) cancel() // ← 循环内不能用 defer!立即调用 if err == nil { return data, nil } lastErr = err // 外层已取消(总预算耗尽或用户放弃),不再重试 if ctx.Err() != nil { return nil, ctx.Err() } // 退避等待,且等待过程也可被取消 select { case <-time.After(time.Duration(i+1) * 500 * time.Millisecond): case <-ctx.Done(): return nil, ctx.Err() } } return nil, lastErr }
defer 要到函数返回时才执行。在循环里 defer cancel() 会导致本轮迭代的 Context 一直不释放,累积泄漏。循环内必须显式立即调用 cancel()。
// 启一个可被停止的后台任务 func startWorker(ctx context.Context) { go func() { ticker := time.NewTicker(1 * time.Second) defer ticker.Stop() for { select { case <-ticker.C: doWork() case <-ctx.Done(): log.Println("worker 收到停止信号,退出") return // ← 干净退出,goroutine 结束 } } }() } // 使用:想停的时候调用 cancel ctx, cancel := context.WithCancel(context.Background()) startWorker(ctx) // ... 运行一段时间 ... cancel() // worker 优雅退出
import "golang.org/x/sync/errgroup" func fetchAll(ctx context.Context, ids []string) ([]Item, error) { g, ctx := errgroup.WithContext(ctx) // ← 任一子任务失败,ctx 自动取消 items := make([]Item, len(ids)) for i, id := range ids { i, id := i, id // Go 1.22+ 不需要这行 g.Go(func() error { item, err := fetchOne(ctx, id) // 传入同一个 ctx if err != nil { return err } items[i] = item return nil }) } if err := g.Wait(); err != nil { return nil, err // 其他 goroutine 已因 ctx 取消而退出 } return items, nil }
errgroup.WithContext 返回的 ctx 在第一个子任务返回 error 时自动取消,其他并行任务会立即收到信号退出——这就是「快速失败」(fail-fast),避免一个失败还傻等其他的。
// 中间件:入口处生成 traceID 注入 ctx 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 = generateID() } ctx := WithTraceID(r.Context(), traceID) next.ServeHTTP(w, r.WithContext(ctx)) }) } // 任意深度的调用处都能取到,无需层层传参 func repository.Find(ctx context.Context, id int) { traceID, _ := TraceIDFrom(ctx) log.Printf("[trace=%s] 查询 id=%d", traceID, id) // 调用下游 RPC 时也把 ctx 传过去,traceID 就实现了跨服务串联 rpc.Call(ctx, ...) }
这 8 条是 Go 官方和社区的共识,背下来能避开 90% 的坑
// ✅ 官方约定:ctx 是第一个参数,命名统一为 ctx func DoSomething(ctx context.Context, arg Arg) error // ❌ 不要放后面,不要改名 func DoSomething(arg Arg, ctx context.Context) error // 不推荐
如果确实不确定用哪个,传 context.TODO(),而不是 nil。传 nil 会导致 ctx.Done() 等方法直接 panic。
// ❌ 危险 doWork(nil, arg) // panic: nil pointer dereference // ✅ 不确定时用 TODO doWork(context.TODO(), arg)
Context 应当随调用流动(flow through),而不是被存储。存进结构体等于把一个「请求生命周期」的对象绑定到了一个可能长期存活的对象上,会造成生命周期错乱和数据串号。
type Service struct { ctx context.Context // 危险! } // 这个 Service 常驻内存,但 ctx 早就该结束了
type Service struct { db *sql.DB // 只存真正的依赖 } func (s *Service) Get(ctx context.Context, id int) { s.db.QueryContext(ctx, ...) }
前面已强调过,这是最重要的一条。WithCancel/WithTimeout/WithDeadline 返回的 cancel 必须调用。
ctx, cancel := context.WithTimeout(parent, 5*time.Second) defer cancel() // ✅ 函数内:defer // 循环内:显式立即调用(不能用 defer) for i := 0; i < n; i++ { c, cancel := context.WithTimeout(ctx, time.Second) use(c) cancel() // ✅ 立即 }
Context 设计为不可变(immutable)——每次 WithXXX 都是返回一个新的 Context,而不是修改原来的。往 Value 里放指针并修改其指向的对象,会破坏并发安全性。
见第 07 节。一句话判断标准:「如果取不到这个值,函数应该报错还是能继续?」能继续 → 可以放 Value;必须报错 → 应该显式声明为参数。
子 Context 的超时会被父截断,所以设计超时时要考虑整体预算:入口设一个总超时(如 10s),各子调用在此基础上分配(DB 3s、RPC 2s…),而不是各自独立设 10s。
调用下游服务/库时,把当前 ctx 原样传下去,而不是新建一个 Background()。否则上游的取消信号在这里断裂——用户已经关掉页面了,下游还在傻干。
// ❌ 信号断裂 func handler(w, r) { ctx := context.Background() // 丢弃了 r.Context() db.QueryContext(ctx, ...) } // ✅ 原样传递 func handler(w, r) { db.QueryContext(r.Context(), ...) }
这些错误在生产环境都真实发生过,每一个的代价都不小
| 陷阱 | 后果 | 正确做法 |
|---|---|---|
| 忘记调用 cancel() | 子 Context 挂在父的 children 里不释放,timer 不停止 → 内存与 goroutine 泄漏 | defer cancel();循环内显式立即调用 |
| 循环内用 defer cancel() | defer 到函数返回才执行,循环 N 次就泄漏 N 个 | 循环体内直接 cancel(),不用 defer |
| 传 nil Context | 调用方法时直接 panic | 用 context.TODO() 占位 |
| Context 存在 struct 里 | 生命周期错乱,长生命周期对象持有已结束的 ctx;并发下数据串号 | 作为方法参数传递 |
| 用 Value 传必需参数 | 编译期无检查,重构时漏改;.(T) 断言 panic;代码可读性极差 |
必需参数显式声明;Value 只放可选元数据 |
| 用字符串做 Value 的 key | 不同包 key 冲突,互相覆盖 | 定义不可导出的 key 类型 + 配套读写函数 |
| Value 类型断言不带 ok | key 不存在或类型不符时 panic | v, ok := ctx.Value(k).(T) |
| 子 goroutine 用 Background 另起炉灶 | 取消信号断裂,父取消时子 goroutine 还在跑 → 泄漏 | 把父 ctx 传进去 |
| 把 ctx 存起来异步使用 | 请求已结束、ctx 已取消,异步任务一起被取消,或读到过期数据 | 异步任务用新的 context.Background() + 自己的超时 |
| 用 == 比较超时错误 | 错误被包装(*url.Error)后判断失败,超时处理逻辑失效 |
errors.Is(err, context.DeadlineExceeded) |
| 热循环里反复 Value() | Value 沿链向上查找是 O(链深),高频调用有性能损耗 | 循环外取一次存局部变量 |
| 以为 cancel 能"杀掉"goroutine | Context 是协作式的,不响应 Done() 的 goroutine 照样泄漏 | 所有阻塞点都要 select 监听 ctx.Done() |
这是最常见也最隐蔽的坑。HTTP handler 里起了个 goroutine 做异步任务(如发通知),并且直接用了 r.Context()。结果请求一返回,ctx 就被取消,异步任务跟着挂掉(或者更糟:任务执行一半被中断)。
// ❌ 错误:用了请求 ctx,请求结束任务就被取消 go sendNotification(r.Context(), msg) // ✅ 正确:异步任务用新 ctx,自带超时,脱离请求生命周期 go func() { ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() sendNotification(ctx, msg) }()
点击展开
ctx.Done() 并主动 return。如果一个 goroutine 在跑纯计算、从不检查 Done,那它永远不会因 ctx 取消而停止。所以写循环/阻塞代码时,务必在阻塞点加上 select { case <-ctx.Done(): return }。Done() 的类型是 <-chan struct{}——空结构体零内存开销,我们只关心「是否关闭」这个事件,不关心传什么值。time.Timer 对象和它在父节点 children map 里的引用仍在。只有调用 cancel() 才会执行 timer.Stop() 和 removeChild()。另外 cancel 是幂等的(内部靠 err != nil 判断),重复调用完全安全。所以 defer cancel() 永远是正确写法。Deadline() 返回的是继承链上最早的那个时间。这是设计如此:保证「整体耗时不会超出外层预算」。推论:子超时只能比父更严格,无法更宽松。如果你想让某个子任务跑得比父更久,那它就不应该用父的 ctx 派生,而应该另起一个独立的 Context(但通常这说明设计有问题)。*cancelCtx 的 children map,propagateCancel 会退化到「起一个额外 goroutine 转发取消信号」,有额外开销;② 自定义实现容易违反「并发安全」「不可变」等隐含契约,埋下隐患。除非非常清楚自己在做什么,否则一律用标准库提供的 WithCancel/WithTimeout/WithValue 组合。emptyCtx。区别只在语义:Background 表示「我明确这里就是起点」;TODO 表示「我还不确定该用哪个 Context,或者这里还没改造完」。用 TODO 便于后续检索补齐,也被静态检查工具(如 go vet 的部分 linter)用来提示。生产代码里如果长期留着 TODO,说明该重构了。timer.Stop(),定时器会一直存在到触发;② 父节点 children map 里的引用——子 Context 不从 map 里摘除,父节点就一直持有它,整条链都无法被 GC。如果父是 Background()(永不取消),那么这些资源永远不释放——在长跑服务里每秒泄漏一个,几小时就能耗尽内存。go vet -lostcancel(官方自带!)能直接查出「创建了 cancel 但可能没被调用」的代码;② 运行时监控:暴露 runtime.NumGoroutine() 到监控,goroutine 数持续增长就是信号;③ pprof:抓 /debug/pprof/goroutine 看堆积在哪个调用栈上,泄漏的 goroutine 通常卡在同一个 select 或 channel 操作上。