Go context 实战:deadline、取消传播与那些救命的模式
几年前我接手过一个网关服务,半夜 goroutine 数从几千飙到二十万,pprof 一拉,全卡在 channel send 上——下游 API 慢,调用方早断开了,可那堆 goroutine 还在傻等。根因就一句话:有人 context.WithCancel 了却没 defer cancel(),取消信号根本传不下去。后来排查别的服务,又见过另一种死法:handler 没设 deadline,一个慢查询把连接池占满,整台机器跟着雪崩。还有更离谱的,团队里有人把 *sql.DB、配置、甚至 logger 全塞进 ctx.Value,当成了万能口袋,接手的人完全看不出函数到底依赖什么。
context.Context 大概是标准库里讨论最多、也误用最多的类型。多数代码只做最低限度:接住它、传下去、然后忽略取消信号。下面这些是我踩过坑之后总结的用法,能避开绝大多数线上事故。
context 真正用来做什么
三件事:
- deadline / timeout 传播 — "调用者最多等 5 秒,超时就别再阻塞了"。
- 取消传播 — "调用者不再关心了,停下来"。
- 请求范围的值 — 跨切面的少量数据(trace ID、登录态、locale),不污染每个函数签名地透过调用栈传递。
context 不是用来:
- 通用依赖注入。
*sql.DB、*log.Logger显式传递。 - 配置。用 options 结构体。
- 携带可变状态。它不是侧通道。
几个构造函数,先把坑记下来
入门时我最希望有人塞给我这张表——后面每一节其实都在展开它:
| 构造函数 | 用途 | 我踩过的坑 |
|---|---|---|
context.Background() | 整个服务的根 context,请求边界起点 | 在 handler 里拿它当父节点用,等于亲手剪断取消链 |
context.WithCancel | 手动取消子任务 | 忘了 defer cancel(),goroutine 直接泄漏,半夜被叫起来那种 |
context.WithTimeout | "现在 + N 秒"的相对超时 | 用 Background() 当父级,覆盖掉上游更紧的 SLA |
context.WithDeadline | 绝对时间点前必须完成 | 把它和 timeout 混用,业务时刻被相对时长悄悄改写 |
context.WithValue | 请求作用域的元数据 | 拿来传依赖和配置,接手的人完全猜不到函数依赖什么 |
取消 — 正确做法
错:
func doWork() {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
// ... 从未把 ctx 传下去
}
对:
func handle(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 3*time.Second)
defer cancel()
result, err := backend.Fetch(ctx, r.URL.Path)
if err != nil {
http.Error(w, err.Error(), 500)
return
}
w.Write(result)
}
两点关键:r.Context() 是父 context(客户端断开时它会被取消),cancel() 一定会执行(defer 保障),即便函数提前 return。
Deadline vs Timeout
ctx, _ := context.WithDeadline(parent, time.Date(2026, 5, 2, 12, 0, 0, 0, time.UTC))
ctx, _ := context.WithTimeout(parent, 500*time.Millisecond)
WithDeadline 是绝对时间,WithTimeout 是"现在 + 时长"。按业务选:
- 用户端 handler 必须按 SLA 响应 →
WithTimeout。 - 批处理必须在某个业务时刻前完成 →
WithDeadline。
是传播,不是包装
接到 context 的函数应原样传给下游,除非你要加更紧的约束:
// 好 —— 给慢调用加更紧 deadline
func loadOrderHistory(ctx context.Context, userID string) ([]Order, error) {
ctx, cancel := context.WithTimeout(ctx, 800*time.Millisecond)
defer cancel()
return db.QueryContext(ctx, query, userID).Scan(...)
}
// 坏 —— 覆盖了父 context 的 200ms SLA
func loadOrderHistory(ctx context.Context, userID string) ([]Order, error) {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
// ...
}
context.Background() 在你的服务里应该恰好出现一次:最顶层,请求边界开始的地方。
context.Value — 把不舒服的 API 用对
Value(key any) any 设计上就别扭。标准库故意让你三思。
type ctxKey string
const requestIDKey ctxKey = "request-id"
func WithRequestID(ctx context.Context, id string) context.Context {
return context.WithValue(ctx, requestIDKey, id)
}
func RequestID(ctx context.Context) string {
if v, ok := ctx.Value(requestIDKey).(string); ok { return v }
return ""
}
要点:key 用私有类型避免冲突,加 typed accessor。永远这么写——别直接用字符串字面量。
适合放:trace ID、登录用户、locale、tenant ID。不该放:数据库 handle、feature flag client、配置结构体。
监听取消
长跑 goroutine:
for {
select {
case <-ctx.Done():
return ctx.Err()
case work := <-workCh:
process(work)
}
}
ctx.Done() 返回一个 channel,context 被取消时关闭。ctx.Err() 告诉你是 context.Canceled 还是 context.DeadlineExceeded——两者重试策略不同。
goroutine 泄漏模式与修法
错:
func search(query string) <-chan Result {
out := make(chan Result)
go func() {
defer close(out)
for r := range slowAPI.Stream(query) {
out <- r // 调用方不读就永远阻塞
}
}()
return out
}
调用方一不读,goroutine 永远阻塞在 send,泄漏。
对:
func search(ctx context.Context, query string) <-chan Result {
out := make(chan Result)
go func() {
defer close(out)
for r := range slowAPI.Stream(query) {
select {
case out <- r:
case <-ctx.Done():
return
}
}
}()
return out
}
select 上 ctx.Done() 是安全阀。每个长跑 send/receive 都配一个。
反模式
context.TODO()留在生产代码。 它是占位符,换掉。ctx context.Context不放第一个参数。 约定永远第一个。- 用
time.Sleep而不是select { case <-ctx.Done(): case <-time.After(...): }。 Sleep 忽略取消。 - 客户端断开就 ERROR 级别打 ctx.Err()。
context.Canceled降级到 debug,它是正常的。 - 请求 handler 里给子函数传
context.Background()。 你刚刚切断了取消链。
一句话总结
- 任何做 I/O 或耗时工作的函数都接
ctx context.Context作第一个参数。 - 派生 context 立刻
defer cancel()。 - 原样传父 context,除非要加更紧约束。
context.Value节制使用,typed key,跨切面请求数据用。- 每个长跑 send/receive 配一个
select <-ctx.Done()。
五条做对,省一半生产事故。
最后唠叨两句我自己最较真的地方:ctx.Value 别当全局变量用,只放请求作用域的元数据,trace ID、登录用户这种,别往里塞依赖;每个 WithCancel 后面那行 defer cancel() 不是可选项,少一个就是一个潜在的泄漏点,我现在 code review 看到 WithCancel/WithTimeout 第一反应就是往下扫有没有 cancel。这东西没什么高深的,无非是养成肌肉记忆——栽过一次就忘不掉了。祝你别在凌晨三点跟 goroutine 泄漏面对面。
我们是一支开发者小团队,负责构建和维护 ToolsForge —— 150+ 个完全在浏览器本地运行、隐私优先的工具。这些文章来自我们日常的工程实践,而不是纸上谈兵。
了解我们相关阅读
Swift 并发 2026:async/await、Actor 与 Sendable 严格检查
务实的 Swift 并发指南 — async/await 用法、Actor 与 @MainActor、结构化并发 TaskGroup、Swift 6 strict Sendable 检查及老回调 API 迁移。
一张照片能做多少事:AI 改图工具实战指南
换发型、换发色、换装、转卡通、风格迁移、室内设计、老照片修复——用一张照片走完这些 AI 改图流程,以及怎么让结果更稳、更像本人。
把脸保住:AI 改图提示词怎么写才不"换人"
换发型、换装、换风格时,AI 老把脸也改了?这篇讲清楚改图提示词的写法:保人约束、双通道(gpt-image-2 / Nano Banana)各自的脾气,以及一套能直接抄的模板。