Go-17 context 上下文控制
在真实的服务端程序里,一个请求往往会衍生出成百上千个 goroutine:查数据库、调下游 RPC、读缓存、写日志……如果客户端断开连接,或者请求超时了,这些 goroutine 应该怎么办?答案是——它们必须能被统一地、及时地取消掉,否则就会持续占用连接、内存和 CPU,最终拖垮整个服务。context 就是 Go 为此设计的标准答案:它把"取消信号"“超时时间"“请求作用域数据"打包成一棵可以沿调用链向下传递的树。本章从动机讲到接口,从四种派生方式讲到底层的取消传播原理,最后落到最佳实践、常见陷阱与面试题(代码均为 Go 1.22+,含中文注释)。
1. 为什么需要 context
1.1 goroutine 没有"父子"概念,也没有"杀死"手段
Go 里启动 goroutine 极其廉价,但 Go 故意没有提供 kill goroutine 的 API。一个 goroutine 只能自己 return 才会结束,外部无法强制终止它。这就带来一个核心问题:谁来通知它该退出了?
考虑一个典型场景:HTTP handler 里开了一个 goroutine 去做后台计算,客户端等了 3 秒不耐烦断开了连接。如果没有取消机制,这个 goroutine 会傻乎乎地把活干完,结果没人要,白白浪费资源。
// 反例:没有取消机制,goroutine 无法感知外界变化,可能永久泄漏
func handler() {
ch := make(chan int)
go func() {
// 假设这里是一个很慢的操作
result := slowCompute()
ch <- result // 如果没人接收,这个 goroutine 永远阻塞在这里 → 泄漏
}()
// ... 如果 handler 提前返回,上面的 goroutine 就泄漏了
}
1.2 context 要解决的四类问题
| 问题 | 说明 | context 的手段 |
|---|---|---|
| 生命周期管理 | 请求结束时,级联结束所有衍生 goroutine | Done() channel |
| 超时控制 | 一个请求最多允许执行多久 | WithTimeout / WithDeadline |
| 主动取消 | 用户断连、上游放弃时立即回收 | WithCancel |
| 请求作用域数据 | 沿调用链传递 traceID、用户身份等元数据 | WithValue |
一句话总结:context 是一个在多个 goroutine 之间安全传递"取消信号 + 截止时间 + 请求数据"的对象。它是 Go 并发编程里"控制流"的标准载体。
2. Context 接口
context.Context 本身只是一个接口,只有四个方法。理解这四个方法,就理解了 context 的全部对外行为。
type Context interface {
// Deadline 返回该 context 被自动取消的时间点。
// ok 为 false 表示没有设置截止时间。
Deadline() (deadline time.Time, ok bool)
// Done 返回一个 channel,当 context 被取消或超时,该 channel 会被 close。
// 多次调用返回同一个 channel。若该 context 永不取消,返回 nil。
Done() <-chan struct{}
// Err 说明 Done 为什么被关闭:
// - context 还没结束:返回 nil
// - 被主动 cancel:返回 context.Canceled
// - 超时:返回 context.DeadlineExceeded
Err() error
// Value 根据 key 取出沿链传递的值,找不到返回 nil。
Value(key any) any
}
四个方法各司其职:
- Done() 是核心。它返回一个"信号 channel”,消费者通过
select监听它。channel 被 close 是一种高效的"广播”——所有监听者会同时收到零值,这是 Go 实现"一对多通知"的惯用手法。 - Err() 用来事后判断取消原因,通常在
<-ctx.Done()之后调用。 - Deadline() 让下游可以据此调整自己的行为(比如剩余时间不够就不发起新请求)。
- Value() 用来读取请求元数据。
典型消费姿势:
func worker(ctx context.Context) {
for {
select {
case <-ctx.Done():
// 收到取消/超时信号,立即清理并退出
fmt.Println("worker 退出,原因:", ctx.Err())
return
default:
// 干一小段活,然后回到 select 检查
doPieceOfWork()
}
}
}
关键点:context 只负责"发信号",并不会真的中断你的代码。是否退出、何时退出,取决于你的代码有没有去
select监听Done()。context 是一种协作式(cooperative)取消,不是抢占式。
3. 根 context:Background 与 TODO
任何 context 树都要有一个根节点。标准库提供两个根:
ctx1 := context.Background() // 最常用的根 context
ctx2 := context.TODO() // 占位用的根 context
它们的底层实现完全一样(都是 emptyCtx),行为也一样:
// 源码级别,两者都是 emptyCtx,永不取消、无截止时间、无值
func Background() Context { return backgroundCtx{} }
func TODO() Context { return todoCtx{} }
区别只在语义上:
| 根 context | 何时使用 |
|---|---|
context.Background() |
main 函数、初始化、测试,或作为整个请求链的顶层根 |
context.TODO() |
你还不确定该用哪个 context,或函数尚未接入 context,先占位,提醒自己"这里以后要补" |
TODO 的存在纯粹是给人看的信号:静态分析工具(如 go vet、golangci-lint)能识别它,提示这里的 context 传递尚未完成。生产代码里绝大多数情况用 Background()。
func main() {
// 请求链的最顶端,用 Background 作根
ctx := context.Background()
// 由根派生出各种带能力的子 context
ctx, cancel := context.WithTimeout(ctx, 5*time.Second)
defer cancel()
run(ctx)
}
4. context.WithCancel:手动取消
WithCancel 派生出一个可以被"手动叫停"的子 context。它返回子 context 和一个 cancel 函数,调用 cancel() 就会关闭该 context 的 Done() channel。
func WithCancel(parent Context) (ctx Context, cancel CancelFunc)
4.1 基础实例:主动通知一组 worker 停工
package main
import (
"context"
"fmt"
"sync"
"time"
)
func main() {
ctx, cancel := context.WithCancel(context.Background())
var wg sync.WaitGroup
for i := 1; i <= 3; i++ {
wg.Add(1)
go func(id int) {
defer wg.Done()
for {
select {
case <-ctx.Done():
// 收到取消信号,退出
fmt.Printf("worker %d 退出:%v\n", id, ctx.Err())
return
default:
fmt.Printf("worker %d 工作中...\n", id)
time.Sleep(300 * time.Millisecond)
}
}
}(i)
}
// 主 goroutine 干 1 秒后统一叫停所有 worker
time.Sleep(1 * time.Second)
cancel() // 一次调用,三个 worker 同时收到信号
wg.Wait()
fmt.Println("所有 worker 已退出")
}
4.2 cancel 必须被调用,否则泄漏
这是 context 使用中最容易踩的坑,也是 go vet 会重点检查的问题。每一个 WithCancel / WithTimeout / WithDeadline 返回的 cancel 函数都必须被调用,即使 context 已经因超时自动取消了也要调。
原因在于:派生 context 时,子节点会被登记到父节点的 children map 里(见第 7 节)。如果你不调 cancel,即便这个子 context 逻辑上已经用完,它仍然挂在父节点上,导致父节点无法释放这部分内存,直到父节点自己被取消——这就是"context 泄漏"。
// 正确写法:defer cancel(),保证任何返回路径都会释放
func fetch(parent context.Context) error {
ctx, cancel := context.WithCancel(parent)
defer cancel() // 即使下面提前 return,也会执行
result := make(chan int, 1)
go func() {
result <- compute(ctx)
}()
select {
case r := <-result:
fmt.Println("结果:", r)
return nil
case <-ctx.Done():
return ctx.Err()
}
}
记忆口诀:拿到 cancel,立刻
defer cancel()。重复调用 cancel 是安全的(幂等),所以 defer 一次绝不会错。
4.3 CancelFunc 的语义
type CancelFunc func()
cancel() 做三件事:关闭自己的 Done() channel、把 Err() 置为 context.Canceled、递归取消所有子 context。它是并发安全且幂等的——被调用多次只有第一次生效。
5. WithTimeout 与 WithDeadline:超时自动取消
这两个函数派生出会"自动到点取消"的 context,是超时控制的主力。
// 相对时间:从现在起 timeout 后取消
func WithTimeout(parent Context, timeout time.Duration) (Context, CancelFunc)
// 绝对时间:到 d 这个时间点取消
func WithDeadline(parent Context, d time.Time) (Context, CancelFunc)
WithTimeout(parent, d) 其实就是 WithDeadline(parent, time.Now().Add(d)) 的语法糖。二者内部都用 timerCtx,靠一个 time.Timer 到点触发取消。
5.1 HTTP 请求超时(最经典场景)
package main
import (
"context"
"fmt"
"io"
"net/http"
"time"
)
func main() {
// 整个请求最多允许 2 秒
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
// 用 NewRequestWithContext 把 ctx 绑定到请求上
req, _ := http.NewRequestWithContext(ctx, http.MethodGet,
"https://httpbin.org/delay/5", nil)
resp, err := http.DefaultClient.Do(req)
if err != nil {
// 超时后 err 会是 context.DeadlineExceeded 包裹的错误
fmt.Println("请求失败:", err)
return
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
fmt.Println("响应:", string(body))
}
net/http 内部会监听 ctx.Done(),一旦超时会主动中断底层 TCP 连接。这就是"传对了 context"能带来的自动取消——你不用写任何 select。
5.2 数据库查询超时
标准库 database/sql 的所有方法都有带 context 的版本(QueryContext、ExecContext 等):
func queryUser(db *sql.DB, id int64) (string, error) {
// 单条查询最多 500ms
ctx, cancel := context.WithTimeout(context.Background(), 500*time.Millisecond)
defer cancel()
var name string
// 超时后驱动会向数据库发送取消指令(如 MySQL 的 KILL QUERY)
err := db.QueryRowContext(ctx, "SELECT name FROM users WHERE id = ?", id).
Scan(&name)
if err != nil {
return "", fmt.Errorf("查询失败: %w", err)
}
return name, nil
}
5.3 WithDeadline:多个操作共享同一截止点
当一个请求有总预算(比如整条链路 1 秒),后面的每一步都应该以同一个绝对时间点为界,而不是各自重新计时。这时用 WithDeadline 更精确:
func pipeline(parent context.Context) error {
// 整条流水线的硬截止点
deadline := time.Now().Add(1 * time.Second)
ctx, cancel := context.WithDeadline(parent, deadline)
defer cancel()
if err := stepA(ctx); err != nil { // stepA 用掉一部分时间
return err
}
// stepB 自动继承剩余时间,不会重新给满 1 秒
return stepB(ctx)
}
派生时的一个自动优化:如果父 context 的 deadline 比你要设的更早,
WithDeadline会直接沿用父的 deadline,不会新建定时器——子永远不会比父活得更久。
5.4 超时后如何判断原因
select {
case <-ctx.Done():
switch ctx.Err() {
case context.DeadlineExceeded:
fmt.Println("超时了")
case context.Canceled:
fmt.Println("被主动取消了")
}
}
6. context.WithValue:传递请求作用域数据
WithValue 派生出一个携带键值对的 context,用于沿调用链传递请求作用域的元数据,比如 traceID、请求 ID、用户身份等。
func WithValue(parent Context, key, val any) Context
6.1 key 必须用自定义类型,避免冲突
Value 的 key 是 any,如果直接用字符串 "userID" 做 key,不同的包可能用了同名字符串导致相互覆盖。规范做法是定义一个包内私有类型作为 key:
package auth
import "context"
// 定义一个私有类型,外部包无法构造出相同的 key,天然隔离
type ctxKey int
const userKey ctxKey = 0 // 未导出,别的包拿不到
// 提供 setter,封装 key
func WithUser(ctx context.Context, u *User) context.Context {
return context.WithValue(ctx, userKey, u)
}
// 提供 getter,做类型断言,隐藏内部细节
func UserFrom(ctx context.Context) (*User, bool) {
u, ok := ctx.Value(userKey).(*User)
return u, ok
}
调用方只通过 WithUser / UserFrom 存取,既避免 key 冲突,又不暴露内部 key,是社区公认的最佳模式。
6.2 完整示例:链路中传递 traceID
package main
import (
"context"
"fmt"
)
type ctxKey string
const traceIDKey ctxKey = "traceID"
func main() {
ctx := context.WithValue(context.Background(), traceIDKey, "abc-123")
handleRequest(ctx)
}
func handleRequest(ctx context.Context) {
// 无需层层传参,ctx 一路带着 traceID
logWithTrace(ctx, "开始处理请求")
callDownstream(ctx)
}
func callDownstream(ctx context.Context) {
logWithTrace(ctx, "调用下游服务")
}
func logWithTrace(ctx context.Context, msg string) {
traceID, _ := ctx.Value(traceIDKey).(string)
fmt.Printf("[trace=%s] %s\n", traceID, msg)
}
6.3 只传"请求元数据",不传"函数参数"
这是 WithValue 最容易被滥用的地方。官方文档明确写道:
Use context Values only for request-scoped data that transits processes and APIs, not for passing optional parameters to functions.
判断标准:
| 适合放进 Value | 不适合放进 Value |
|---|---|
| traceID / requestID | 数据库连接、logger 等依赖 |
| 认证后的用户身份 | 函数的可选配置项 |
| 客户端 IP、语言偏好 | 业务逻辑需要的核心入参 |
原因是:Value 是隐式的、无类型约束的、编译期无法检查的。如果一个值是函数逻辑必需的,就应该作为显式参数传,让编译器帮你把关,而不是塞进 context 里"暗度陈仓"。
7. context 底层原理
理解原理才能真正用对 context。核心是三种内部结构:cancelCtx、timerCtx、valueCtx,它们通过嵌套形成一棵由子指向父的树。
7.1 三种内部结构
// 可取消的 context,WithCancel 的底层
type cancelCtx struct {
Context // 嵌入父 context
mu sync.Mutex // 保护以下字段
done atomic.Value // 惰性创建的 Done channel
children map[canceler]struct{} // 记录所有可取消的子节点
err error // 取消原因
cause error // 取消的根本原因(Go 1.20+)
}
// 带定时器的 context,WithTimeout/WithDeadline 的底层
type timerCtx struct {
cancelCtx // 内嵌 cancelCtx,复用取消逻辑
timer *time.Timer // 到点触发取消
deadline time.Time
}
// 携带键值的 context,WithValue 的底层
type valueCtx struct {
Context // 嵌入父 context
key, val any // 只存一个键值对
}
几个关键设计:
- 每个 context 都内嵌父 context(
Context字段),所以形成一条向上的链。 cancelCtx.children是一个 map,记录直接子节点,用于取消时向下广播。donechannel 是惰性创建的:只有第一次调用Done()才分配,没人监听就不浪费。valueCtx每次只存一个 key/val,多个值会形成多层嵌套。
7.2 context 树 ASCII 图
假设代码如下:
root := context.Background()
ctxA, _ := context.WithCancel(root)
ctxB := context.WithValue(ctxA, k1, v1)
ctxC, _ := context.WithTimeout(ctxB, time.Second)
ctxD, _ := context.WithCancel(ctxA)
形成的树(箭头表示"子 → 父"的嵌入关系):
Background (emptyCtx)
▲
│ 嵌入
┌──────┴──────┐
ctxA (cancelCtx) ← children: {ctxB链下的ctxC, ctxD}
▲ ▲
│ │
ctxB (valueCtx) ctxD (cancelCtx)
▲
│
ctxC (timerCtx)
取消传播方向:从上往下(父 cancel → 子全 cancel)
数据查找方向:从下往上(Value 沿嵌入链回溯)
注意一个微妙点:valueCtx 本身不可取消。所以 ctxA 的 children 里登记的不是 ctxB,而是"往下找到的第一个可取消节点" ctxC。这个"跳过不可取消节点、挂到最近的可取消祖先"的逻辑,正是 propagateCancel 干的事。
7.3 valueCtx 的链式查找
Value 的查找是沿嵌入链从当前节点向上回溯,直到命中或到达根:
// 简化后的查找逻辑
func (c *valueCtx) Value(key any) any {
if c.key == key {
return c.val // 当前层命中
}
return c.Context.Value(key) // 未命中,向父层继续找
}
因此 Value 的时间复杂度是 O(链深度)。这也是"不要往 context 里塞大量值"的另一个理由——链越长,查找越慢,且越靠外层的值查找越慢。
8. 取消传播机制
8.1 派生时:propagateCancel 建立父子链接
当你 WithCancel(parent) 时,内部会调用 propagateCancel,把新的子节点挂到"最近的可取消祖先"上:
// 极简伪代码,展现核心思路
func propagateCancel(parent Context, child canceler) {
if parent.Done() == nil {
return // 父永不取消(如 Background),无需挂接
}
// 1. 父已经取消了?子立刻取消
select {
case <-parent.Done():
child.cancel(false, parent.Err())
return
default:
}
// 2. 向上找到最近的 *cancelCtx 祖先,把 child 登记进它的 children
if p, ok := parentCancelCtx(parent); ok {
p.children[child] = struct{}{}
} else {
// 3. 找不到(父是自定义 context),起一个 goroutine 桥接监听
go func() {
select {
case <-parent.Done():
child.cancel(false, parent.Err())
case <-child.Done():
}
}()
}
}
8.2 取消时:cancel 沿 children 递归向下
调用 cancel() 时会遍历 children,逐个取消子节点,子节点再取消自己的子节点,形成递归下沉:
// 简化的 cancel 逻辑
func (c *cancelCtx) cancel(removeFromParent bool, err, cause error) {
c.mu.Lock()
c.err = err // 记录取消原因
close(c.doneChannel()) // 关闭 Done → 所有监听者被广播唤醒
for child := range c.children {
child.cancel(false, err, cause) // 递归取消所有子节点
}
c.children = nil // 清空,帮助 GC
c.mu.Unlock()
if removeFromParent {
removeChild(c.Context, c) // 从父的 children 中摘除自己
}
}
8.3 close(channel) 是"一对多广播"的关键
为什么 Done() 用 close channel 而不是往里发值?因为一个已关闭的 channel,任意多的接收者都能立刻读到零值且不阻塞。所以一次 close,成百上千个监听 <-ctx.Done() 的 goroutine 会被同时唤醒——这正是取消信号"一对多广播"的底层机制。
cancel() 调用
│
▼
close(Done channel) ────广播────► goroutine 1: <-ctx.Done() 返回
│ goroutine 2: <-ctx.Done() 返回
│ 递归 goroutine 3: <-ctx.Done() 返回
▼
遍历 children 逐个 cancel
│
┌─────┼─────┐
▼ ▼ ▼
子ctx 子ctx 子ctx (继续向下递归...)
一句话:父取消 → children 递归取消 → 每个节点 close 自己的 Done → 所有监听者被唤醒。整个过程是自上而下、级联的。
9. 最佳实践
社区与官方沉淀出的一套规范,逐条列出:
(1)context 作为函数第一个参数,命名为 ctx
// 正确:ctx 永远排第一
func DoSomething(ctx context.Context, arg Arg) error
// 错误:不要放中间或最后
func DoSomething(arg Arg, ctx context.Context) error
(2)不要把 context 存进结构体字段
context 应该显式地随调用传递,而不是藏在 struct 里跟着对象跑。存进 struct 会让生命周期变得模糊,也难以追踪谁在用哪个 context。
// 反例:不要这样
type Server struct {
ctx context.Context // ✗ 生命周期不清晰
}
// 正例:每次方法调用显式传入
func (s *Server) Handle(ctx context.Context, req *Request) error
例外:极少数长生命周期对象(如某些框架的请求对象)会存 context,但这是框架级设计,业务代码应避免。
(3)永远不要传 nil context
不确定用哪个时传 context.TODO(),而不是 nil。给 nil 会在调用 Done() / Value() 时 panic。
// 错误
DoSomething(nil, arg) // ✗ 后续操作会 panic
// 正确
DoSomething(context.TODO(), arg) // ✓
(4)拿到 cancel 立刻 defer
ctx, cancel := context.WithTimeout(parent, time.Second)
defer cancel() // 第一时间写下,防止遗漏
(5)WithValue 只传请求元数据,不传函数参数(见第 6.3 节)
(6)context 是"向下传递"的,不要试图反向修改父 context
派生只会产生新的子 context,父 context 完全不受影响。context 是不可变的(immutable)。
10. 常见误用与陷阱
10.1 忘记 cancel 导致泄漏
最高频的错误。go vet 会报 the cancel function returned by context.WithTimeout should be called。
// 反例:cancel 被丢弃(用 _ 接收),子 context 泄漏
ctx, _ := context.WithTimeout(parent, time.Second) // ✗ lostcancel
// 正例
ctx, cancel := context.WithTimeout(parent, time.Second)
defer cancel() // ✓
10.2 用 WithValue 传业务参数
// 反例:把分页参数塞进 context
ctx = context.WithValue(ctx, "pageSize", 20)
// ... 深处再取出来
size := ctx.Value("pageSize").(int) // ✗ 隐式、易 panic、编译器无法检查
// 正例:作为显式参数
func List(ctx context.Context, pageSize int) { ... } // ✓
10.3 超时未生效——因为没把 ctx 传下去
设了超时,但下游函数根本没用这个 ctx,超时就是摆设:
// 反例
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
defer cancel()
resp, _ := http.Get(url) // ✗ 用了默认 context,超时无效!
// 正例:ctx 必须真正传进去
req, _ := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
resp, _ := http.DefaultClient.Do(req) // ✓
10.4 阻塞操作没有监听 Done
取消是协作式的,如果你的循环/阻塞里没 select ctx.Done(),取消信号来了也没人理:
// 反例:纯 for 死循环,取消信号进不来
for {
doWork() // ✗ 永远不检查 ctx
}
// 正例
for {
select {
case <-ctx.Done():
return ctx.Err()
default:
doWork()
}
}
10.5 对已取消的 context 继续派生
对一个已经取消的 context 派生子 context,子 context 会立即处于已取消状态(这是设计如此,不是 bug,但要心里有数):
parent, cancel := context.WithCancel(context.Background())
cancel() // 父已取消
child, _ := context.WithCancel(parent)
<-child.Done() // 立即返回,child.Err() == context.Canceled
11. Go 1.20+ 的新增能力
Go 1.20/1.21 给 context 包补了几个实用函数,面试与实战都值得掌握。
11.1 context.WithoutCancel(Go 1.21)
派生一个"继承父的 Value,但脱离父的取消/超时“的 context。适合"请求已结束,但我要在后台异步做点收尾(如异步写审计日志),不希望被请求的取消波及"的场景。
func handler(ctx context.Context) {
// 请求结束后 ctx 会被取消,但我想异步落审计日志
bgCtx := context.WithoutCancel(ctx) // 保留 traceID 等 Value,但不会被取消
go func() {
// 即便原 ctx 已取消,这里也能跑完
writeAuditLog(bgCtx)
}()
}
11.2 context.WithDeadlineCause / WithTimeoutCause(Go 1.21)
在超时的同时指定一个"原因 error”,超时后可用 context.Cause(ctx) 取出更具体的错误,比笼统的 DeadlineExceeded 更利于排查。
cause := errors.New("下游 payment 服务预算耗尽")
ctx, cancel := context.WithTimeoutCause(parent, 2*time.Second, cause)
defer cancel()
<-ctx.Done()
fmt.Println(ctx.Err()) // context.DeadlineExceeded(还是通用错误)
fmt.Println(context.Cause(ctx)) // 下游 payment 服务预算耗尽(自定义原因)
配套的 context.Cause(ctx) 也能配合 WithCancelCause 使用:
ctx, cancel := context.WithCancelCause(parent)
cancel(errors.New("用户主动放弃")) // 取消时带上原因
fmt.Println(context.Cause(ctx)) // 用户主动放弃
11.3 context.AfterFunc(Go 1.21)
注册一个"当 ctx 结束(取消或超时)时自动异步执行"的回调,省去自己起 goroutine 监听 Done()。返回一个 stop 函数用来取消注册。
ctx, cancel := context.WithCancel(context.Background())
// ctx 结束后自动在新 goroutine 里执行清理
stop := context.AfterFunc(ctx, func() {
fmt.Println("ctx 结束,执行资源清理")
})
defer stop() // 若不再需要,可注销
cancel() // 触发上面的回调
AfterFunc 让"context 结束时做点事"变成一行注册,是替代手写 go func(){ <-ctx.Done(); ... }() 的更简洁写法。
12. 高频面试题
Q1:context 的作用是什么?
在多个 goroutine 之间安全地传递三样东西:取消信号、截止时间、请求作用域数据。核心价值是统一管理一次请求衍生出的所有 goroutine 的生命周期,实现级联取消与超时控制,防止 goroutine 泄漏。
Q2:context 有哪几种类型(派生方式)?
四类派生 + 两个根:
- 根:
Background(顶层根)、TODO(占位); WithCancel:手动取消(底层cancelCtx);WithTimeout/WithDeadline:超时自动取消(底层timerCtx);WithValue:携带键值(底层valueCtx)。
Q3:取消信号是如何传播的?
父取消时遍历自己的 children map,递归调用每个子节点的 cancel;每个节点 cancel 时会 close 自己的 Done channel。因为 close 后的 channel 能让任意多接收者同时读到零值,所以所有监听 <-ctx.Done() 的 goroutine 会被同时唤醒。方向是自上而下、级联广播。派生时通过 propagateCancel 把子挂到最近的可取消祖先上(跳过 valueCtx 这类不可取消节点)。
Q4:WithValue 使用要注意什么?
三点:① key 用包内私有类型,避免跨包冲突;② 只传请求作用域元数据(traceID、用户身份),不传函数可选参数或依赖;③ 查找是 O(链深度) 的向上回溯,别塞太多值。最好封装成 WithXxx / XxxFrom 一对函数暴露。
Q5:为什么 cancel 要用 defer 调用?
因为派生 context 时子节点会被登记到父节点的 children map,如果不调 cancel,子节点会一直挂在父节点上无法被 GC 回收,直到父节点自己取消——造成内存泄漏。defer cancel() 保证任何返回路径都会释放;cancel 幂等,多调也安全。go vet 会检查这个问题。
Q6:context 能不能用来传函数的可选参数?
不能(不应该)。context 的 Value 是隐式的、无类型约束、编译期不可检查的。函数逻辑必需的参数应显式传参,让编译器把关。Value 只用于"跨 API/进程边界的请求元数据"。官方文档明确禁止用它传可选参数。
Q7:context 取消是抢占式的吗?
不是。context 是协作式取消——它只关闭 Done channel 发信号,并不会真正中断你的代码。goroutine 必须自己 select { case <-ctx.Done(): return } 主动响应才会退出。如果代码里不监听 Done,取消信号来了也无效。
Q8:Background 和 TODO 有什么区别?
底层实现完全一样(都是 emptyCtx,永不取消、无 deadline、无值),区别只在语义:Background 用于明确的顶层根;TODO 表示"这里以后要接入 context,暂时占位",供静态分析工具识别。生产代码绝大多数用 Background。
Q9:子 context 的超时会比父 context 长吗?
不会。派生时若父的 deadline 比子设置的更早,会直接沿用父的 deadline,不新建定时器。子 context 的生命周期永远不超过父——父取消,子必取消。
小结
- context 是 Go 控制流的标准载体:在多 goroutine 间传递取消信号、超时和请求数据,核心目标是统一管理请求生命周期、防止 goroutine 泄漏。
- 接口只有四个方法:
Deadline/Done/Err/Value;Done()返回的 channel 通过 close 实现一对多广播,是取消机制的基石。 - 两个根、四种派生:
Background/TODO作根;WithCancel手动取消、WithTimeout/WithDeadline超时取消、WithValue携带元数据。 - 原理:
cancelCtx用childrenmap 维护子节点,取消时递归下沉并 close 各自的 Done;timerCtx靠定时器到点取消;valueCtx沿嵌入链向上回溯查找。 - 黄金法则:ctx 作第一个参数、别存 struct、别传 nil、拿到 cancel 立刻
defer cancel()、Value 只传请求元数据、阻塞处务必select监听Done()。 - Go 1.21 新增:
WithoutCancel(保留值脱离取消)、WithTimeoutCause/Cause(更精确的取消原因)、AfterFunc(结束时自动回调),让 context 用起来更顺手。
把 context 用对,你的并发程序就有了清晰的"控制骨架"——任何一次请求的取消和超时,都能沿着这棵树精准、及时地传导下去。
xingliuhua