导语:context 是 goroutine 生命周期的总开关
用 go 关键字启动一个 goroutine 很简单,难的是如何优雅地让它停下来。在 Go 的工程实践中,一个 HTTP 请求会派生出几十个 goroutine:查缓存、查数据库、调用下游服务、做日志上报。如果请求被客户端断开、被上游超时、或者服务要优雅退出,这些 goroutine 必须被统一通知、统一停止,否则就会泄漏——占用内存、占着连接、拖慢 GC。
context 包正是为此而生。它把"请求范围"(request-scoped)的数据与状态浓缩成一个对象:既可以携带取消信号,让整棵派生树一起停止;也可以携带截止时间,自动触发超时;还可以安全地传递少量跨层数据。
一句话总结:context 是 Go 并发世界里"一根绳子上的全部 goroutine 的遥控器"——取消信号沿着派生树向下传播,超时自动拉闸,值传递保持跨层安全。
1. context 包核心:接口与四种实现
1.1 Context 接口
type Context interface {
// Deadline 返回该 context 的截止时间;若无截止时间则 ok 为 false
Deadline() (deadline time.Time, ok bool)
// Done 返回一个 channel,context 被取消或超时时会被关闭
Done() <-chan struct{}
// Err 说明 Done channel 为何被关闭:
// 返回 Canceled(手动取消)或 DeadlineExceeded(超时)
Err() error
// Value 返回与该 key 关联的值;无关联时返回 nil
Value(key any) any
}
四个方法各司其职:Done() 是所有下游 goroutine 监听的对象;Err() 告诉调用方停止的原因;Deadline() 供调度层判断剩余时间;Value() 是跨层传递数据的唯一入口。
1.2 四个根与派生函数
// 根 context:不会被取消、没有截止时间、不携带任何值
ctx := context.Background()
// 适用于还不知道该用什么 context 的起点,或测试
ctx := context.TODO()
// 可取消:手动调用 cancel() 触发 Done 关闭
ctx, cancel := context.WithCancel(parent)
// 带超时:到达 deadline 后自动取消
ctx, cancel := context.WithTimeout(parent, 3*time.Second)
// 带截止时间:在绝对时间点自动取消
ctx, cancel := context.WithDeadline(parent, time.Now().Add(3*time.Second))
// 带值:携带一个 key-value
ctx := context.WithValue(parent, traceKey, traceID)
其中 Background() 和 TODO() 返回的都是同一个 emptyCtx 实现,只是语义不同:Background() 表示"这里没有父 context",TODO() 表示"这里应该传 context 但还没想好传哪个"——后者的存在是为了让编译器帮你找出遗漏的签名。
1.3 实现的四个典型形态
| 实现 | 触发取消 | 典型来源 |
|---|---|---|
emptyCtx | 永不取消 | Background() / TODO() |
cancelCtx | 调用 cancel() | WithCancel 派生 |
timerCtx | 到达 deadline 自动取消 | WithTimeout / WithDeadline |
valueCtx | 继承父取消,自身只存值 | WithValue 派生 |
valueCtx 不会单独成为取消节点,它只是把 value 挂在父节点旁;取消行为完全由父链上的 cancelCtx 或 timerCtx 决定。
一句话总结:context 只有接口一种契约、四种实现,理解"哪个节点能取消、哪个节点只存值",派生树的设计就清晰了。
2. 取消传播:cancel 树如何向下传导
2.1 WithCancel 的级联本质
func main() {
ctx, cancel := context.WithCancel(context.Background())
defer cancel() // 兜底:确保所有派生 goroutine 最终都能退出
// 派生的 goroutine 监听 ctx.Done()
go worker(ctx, "worker-A")
go worker(ctx, "worker-B")
// 模拟 5 秒后主动取消
time.Sleep(5 * time.Second)
cancel()
// 此时 worker-A、worker-B 以及它们再派生的 goroutine 全部收到信号
}
func worker(ctx context.Context, name string) {
child, _ := context.WithCancel(ctx) // 再次派生:形成取消树
go childWorker(child, name+"-sub")
select {
case <-ctx.Done():
log.Printf("%s 被取消", name)
case <-time.After(30 * time.Second):
log.Printf("%s 完成任务", name)
}
}
func childWorker(ctx context.Context, name string) {
<-ctx.Done() // 父取消会自动传导到这里
log.Printf("%s 收尾清理", name)
}
关键点:WithCancel(ctx) 派生出的新 context,会把自己的 Done() channel 挂在父节点的 children 列表里。当 cancel() 被调用时,父节点会递归地关闭自己、以及所有直接/间接子节点的 Done channel——这就是"取消树向下传播"的机制。
2.2 cancel 函数的幂等与资源释放
ctx, cancel := context.WithCancel(parent)
// 什么时候调用 cancel?三个铁律:
// 1. 尽早 defer cancel(),保证任何返回路径都会执行
// 2. 手动触发时只能调用一次(重复调用是安全的,幂等)
// 3. 每个 WithCancel 派生的 cancel 都要调用,否则父 context 的
// children 列表会残留子节点,造成内存与计算泄漏
func fetchWithContext(ctx context.Context) error {
ctx, cancel := context.WithTimeout(ctx, 2*time.Second)
defer cancel() // 无论 fetch 成功还是失败,都会触发取消
return fetch(ctx)
}
每次 WithCancel 都会向父节点注册一个子节点。如果忘了调用 cancel(),父节点取消时仍要遍历这些残留节点——在"每请求创建大量 context"的服务里,这会累积成明显的 CPU 与内存开销。
一句话总结:取消传播是树状递归,根 cancel 触发整棵子树停止;而"每个派生都要 defer cancel()“是防止父链残留的最重要纪律。
3. 超时与截止时间:WithTimeout / WithDeadline
3.1 两种超时语义
// WithTimeout:相对时长,从创建时刻开始计时
ctx, cancel := context.WithTimeout(ctx, 3*time.Second)
defer cancel()
// WithDeadline:绝对时刻,适合"整个请求必须在 X 时刻前完成"
deadline := time.Now().Add(3 * time.Second)
ctx, cancel := context.WithDeadline(ctx, deadline)
defer cancel()
两者内部都是 timerCtx:启动一个定时器,到点后关闭 Done channel。WithDeadline 更常用于"整个调用链共享一个截止时间"的场景,比如网关把"还剩 5 秒"下传给所有下游。
3.2 读数据库的标准姿势
func queryUser(ctx context.Context, id int) (*User, error) {
// 若父 context 已带超时,这里再叠加一个更短的超时
ctx, cancel := context.WithTimeout(ctx, 2*time.Second)
defer cancel()
row := db.QueryRowContext(ctx, "SELECT name FROM users WHERE id = ?", id)
var u User
if err := row.Scan(&u); err != nil {
if ctx.Err() == context.DeadlineExceeded {
return nil, fmt.Errorf("query user %d timeout", id)
}
return nil, err
}
return &u, nil
}
标准库对 context 的支持(QueryRowContext、http.NewRequestWithContext、net.Dialer.DialContext 等)意味着只要你把 ctx 传下去,超时、取消、连接池回收全都会自动生效。
3.3 DeadlineExceeded 与 Canceled 的区分
func handle(ctx context.Context) error {
select {
case <-ctx.Done():
switch ctx.Err() {
case context.Canceled:
// 手动取消:通常是更上层的请求被放弃
return errors.New("request canceled")
case context.DeadlineExceeded:
// 超时:需要告诉调用方"我慢了",触发重试或降级
return errors.New("deadline exceeded")
}
default:
// 正常执行
}
return nil
}
同样的"停止"行为,原因不同对应的处理策略完全不同:超时一般可重试或返回 504,主动取消则应直接放弃并停止一切后续工作。
一句话总结:超时是"到点自动取消"的定时器,Deadline 是"绝对截止时刻”;下游函数要区分 Canceled 与 DeadlineExceeded,才能做出正确的降级与重试决策。
4. 值传递:WithValue 的正确姿势
4.1 用于跨层传递请求级数据
type traceIDKey struct{} // 使用私有类型作为 key,避免字符串 key 冲突
ctx := context.WithValue(ctx, traceIDKey{}, "trace-abc-123")
// 任意深度读取
func readTraceID(ctx context.Context) (string, bool) {
v, ok := ctx.Value(traceIDKey{}).(string)
return v, ok
}
4.2 四条铁律
1. 不要用内置类型(string、int)直接做 key —— 不同包容易冲突
2. key 必须使用包级私有类型(如 type traceIDKey struct{}),包外无法构造
3. 只传"请求级"数据:traceID、userID、租户 ID、语言偏好
4. 绝不传可变状态、绝不传用于业务逻辑控制的参数 —— 那会隐藏依赖
4.3 WithValue 的查找是向上回溯的
ctx := context.Background()
ctx = context.WithValue(ctx, keyA, "a")
ctx = context.WithValue(ctx, keyB, "b") // 新节点包住旧节点
// ctx.Value(keyA) 会从最外层向内层回溯,找到 keyA 就返回
// 因此"同 key 重复 WithValue"时,内层覆盖外层,且每次查找 O(深度)
在深度派生的树里,Value() 是沿着父子链向上线性查找的。每多一层 WithValue,后续查找就多一次比较。高频热点路径上要控制派生层数,别把值栈拉得太深。
一句话总结:WithValue 只做"请求级数据的跨层传递",用私有类型做 key,查找是向上回溯的——它是传递链上的便利工具,不是依赖注入的替代品。
5. context 派生与组合:树结构的工程实践
5.1 典型的请求上下文树
func HandleRequest(w http.ResponseWriter, r *http.Request) {
// 入口:从请求派生,全局贯穿
ctx := r.Context()
// 分支1:带值(traceID 等)供全链路日志使用
ctx = context.WithValue(ctx, traceIDKey{}, r.Header.Get("X-Trace-ID"))
// 分支2:整体超时,控制整个请求生命周期
ctx, cancel := context.WithTimeout(ctx, 5*time.Second)
defer cancel()
// 分支3:调用下游,叠加更短的超时
resp, err := callDownstream(ctx, r.URL.Path)
if err != nil {
http.Error(w, "upstream error", http.StatusBadGateway)
return
}
_ = resp
}
每个分支都是从同一根派生出去的独立子树:一个分支的取消不影响兄弟分支;兄弟分支共享父级的取消与截止时间。
5.2 合并多个 Done:谁先触发谁生效
func mergeDone(ctxA, ctxB context.Context) <-chan struct{} {
merged := make(chan struct{})
go func() {
defer close(merged)
select {
case <-ctxA.Done():
case <-ctxB.Done():
}
}()
return merged
}
// 场景:一个 goroutine 同时受"请求取消"和"服务关停"两个信号控制
stop := mergeDone(ctx.Done(), appShutdownCh)
<-stop // 任意一个触发,都会继续
这种"信号合并"在优雅关停场景尤其常见:HTTP 请求的 ctx 与进程级的关停信号,任何一个到了都要收工。
5.3 传入 context 的接口约定
// 凡是做 IO 或启动 goroutine 的函数,第一个参数都应是 ctx
type Repository interface {
GetUser(ctx context.Context, id int) (*User, error)
}
// 存储 goroutine 的调用,保证可取消
func StartPolling(ctx context.Context) {
ticker := time.NewTicker(time.Second)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return // 取消即退出,绝不泄漏
case <-ticker.C:
doPoll()
}
}
}
一句话总结:派生树的工程实践是"入口派生根、分支叠加超时、信号可合并";接口里出现 IO 或 goroutine,第一个参数就应该是 ctx。
6. 误用与最佳实践
| 误用 | 现象 | 正确做法 |
|---|---|---|
忘记 defer cancel() | 父链 children 残留,内存/CPU 泄漏 | 每个 WithCancel/WithTimeout 都 defer cancel |
| 把 ctx 存进结构体 | 生命周期混乱,取消不可控 | 作为函数参数显式传递 |
| 用 context 传业务参数 | 依赖被隐藏,难测试 | 只传请求级数据(traceID/userID) |
| 用 string 做 Value 的 key | 不同包 key 冲突 | 包级私有类型做 key |
| 在 select 里只监听 Done 不清理 | goroutine 泄漏 | 配合 ticker.Stop、连接 close 一起收尾 |
| 用 context.Background() 代替一切 | 永远无法取消 | 从入口的 r.Context() 派生 |
| 给没有超时的调用强行加 WithTimeout | 误杀慢请求 | 依据 SLA 设计合理的超时时间 |
| 在 Value 里放不可变结构体 | 数据混乱 | 只放标量型请求级元数据 |
7. 总结
| 机制 | 核心 API | 一句话要义 |
|---|---|---|
| 取消传播 | WithCancel + Done() | 父 cancel 递归关停整棵派生树 |
| 超时 | WithTimeout | 相对时长到点自动取消 |
| 截止时间 | WithDeadline | 绝对时刻统一控制调用链 |
| 值传递 | WithValue | 私有类型 key,只传请求级元数据 |
| 树派生 | 多分支独立 cancel | 兄弟独立、共享父级取消 |
| 信号合并 | select + 多 Done | 请求取消与进程关停任一触发即收工 |
落地记住六件事:入口一律从 r.Context() 派生、每个 WithXxx 都 defer cancel()、区分 Canceled 与 DeadlineExceeded、值传递用私有 key 只放请求元数据、涉及 IO 的函数第一个参数带 ctx、监听 Done 的同时把连接与定时器一并清理。把 context 当作"goroutine 生命周期的总开关",你的并发代码才能在取消、超时、关停三重压力下既不泄漏也不误伤。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。