Go Context 原理
Go context 面试要点整理。覆盖:基本用法、取消与传播、超时与截止时间、传值、底层实现、内存模型、常见模式与陷阱。
基本用法
context(上下文)跨 API 边界传递三类信息:取消信号、截止时间、请求域数据。官方定义:a Context carries a deadline, a cancellation signal, and other values across API boundaries。
Context 接口只有四个方法:
Deadline():返回截止时间。没设置时 ok 为 false。Done():返回一个 channel,context 被取消时关闭。永不可能取消的 context 返回 nil。Err():Done 还没关闭时返回 nil;关闭后返回原因:context.Canceled(主动取消)或context.DeadlineExceeded(超时)。Value(key):按 key 取值,取不到返回 nil。
两个根 context,都不可取消:
context.Background():正式用的根,main 函数、初始化代码里创建。context.TODO():还没想好用哪个 context 时占位,比如函数还没接上调用方的 context。
ctx, cancel := context.WithCancel(context.Background())
go func() {
time.Sleep(10 * time.Millisecond)
cancel()
}()
select {
case <-ctx.Done():
fmt.Println(ctx.Err()) // context canceled
case <-time.After(time.Second):
fmt.Println("timeout")
}实际运行输出 context canceled。
三条传递规则(官方文档原文要求):
- context 作为函数第一个参数,通常命名
ctx,不存进 struct。 - 不要传 nil context。拿不准传什么时传
context.TODO()。 - 同一个 context 可以安全地同时传给多个 goroutine。
取消
WithCancel(parent) 返回一个派生的 context 和 cancel 函数:
ctx, cancel := context.WithCancel(context.Background())
defer cancel() // 函数返回时释放资源取消机制的核心性质:
- 调用 cancel,ctx 的 Done channel 关闭,之后 Err() 返回
context.Canceled。 - cancel 幂等:多次调用只生效一次,第二次起什么都不做。
- 取消会传播:父 context 取消,所有派生的子 context 一起取消。
- 子 context 取消不影响父,也不影响兄弟。
- 不调用 cancel 的代价:资源不释放。WithTimeout 的定时器、propagateCancel 里挂的 goroutine 都留着(见底层实现一节)。
正确姿势是拿到 cancel 立刻 defer cancel(),不是等用完了才想起。
超时与截止时间
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Millisecond)
defer cancel()
<-ctx.Done()
fmt.Println(ctx.Err()) // context deadline exceeded实际运行输出 context deadline exceeded。
WithTimeout(parent, d)就是WithDeadline(parent, time.Now().Add(d)),一层包装。WithDeadline(parent, t)到点自动取消,Err() 返回context.DeadlineExceeded。- 手动 cancel 提前生效,Err() 返回
context.Canceled,不是 DeadlineExceeded。 - 父 context 的 deadline 比新 deadline 更早时,
WithDeadline直接退化成WithCancel(parent):父先到点,子跟着取消,语义等价,省一个定时器。 - deadline 已经过了(
dur <= 0),立即取消,不启动定时器。
区别 Canceled 和 DeadlineExceeded:主动调用 cancel 是前者,到点自动触发是后者。
传值
type key int
var userKey key
ctx := context.WithValue(context.Background(), userKey, "nite")
fmt.Println(ctx.Value(userKey)) // nite
fmt.Println(ctx.Value(key(99))) // <nil>实际运行输出 nite 和 <nil>。
规则:
- 只放请求域数据(request-scoped),不放函数参数。官方原话:not for passing optional parameters to functions。
- key 必须可比较(comparable)。key 不能用内置类型(string、int 等),要自定义类型,避免不同包之间的 key 冲突。
- key 为 nil 或不可比较,
WithValue直接 panic。 - 值查找沿 context 链向上:本层没有就找父,找到返回,找不到返回 nil。链越长查找越慢。
底层实现
源码在 src/context/context.go,Go 1.7 从 x/net/context 搬进标准库。核心是三个结构。
cancelCtx
type cancelCtx struct {
Context
mu sync.Mutex // 保护以下字段
done atomic.Value // chan struct{},惰性创建,第一次 cancel 时关闭
children map[canceler]struct{} // 子 context 集合,第一次 cancel 后置 nil
err atomic.Value // 第一次 cancel 时写入
cause error // 第一次 cancel 时写入
}四个要点:
done惰性创建:没人调用Done()就不分配 channel。第一次调用Done()时才创建。Done()返回值固定:多次调用返回同一个 channel。Value(&cancelCtxKey)返回自身,这是取消传播能找到内层 cancelCtx 的关键(见 propagateCancel)。- children 是 map,cancel 时遍历所有子 context 逐个取消。
done 为什么是 atomic.Value 而不是 bool?done 的职责是通知,不是状态。状态位由 err 字段承担:非 nil 即已取消,cancel() 靠它判断幂等。bool 给不了通知:
select { case <-ctx.Done(): }要一个能阻塞等待的对象,bool 只能轮询,组合不进 select。- channel close 唤醒所有等待者,是广播。一个 context 可能被任意多个 goroutine 同时监听,bool 是单值,没有通知能力。
- channel 关闭自带 happens-before 语义(见内存模型一节),bool 没有。
atomic.Value 是 channel 的容器,用来惰性创建和无锁读。Done() 的实现:
func (c *cancelCtx) Done() <-chan struct{} {
d := c.done.Load()
if d != nil {
return d.(chan struct{})
}
c.mu.Lock()
defer c.mu.Unlock()
d = c.done.Load()
if d == nil {
d = make(chan struct{})
c.done.Store(d)
}
return d.(chan struct{})
}double-checked locking:先无锁 Load,命中直接返回,miss 才拿锁创建。channel 是重对象,多数 context 从没人监听 Done(),惰性创建省下这些分配;热路径上每次 Done() 都是无锁读,用普通 channel 字段就得次次加锁。
历史上 done 最初是构造时直接 make(chan struct{}),Done() 无锁直读,代价是每个 cancelCtx 都白分配一个 channel(x/net/context 时代和 Go 1.7 刚进标准库时都是这样)。Go 1.9 改成惰性创建,用 mutex 保护,Done() 每次都要拿锁;Go 1.20 换成 atomic.Value,读路径无锁。
cancel() 的流程:
err已非 nil 直接返回,这就是幂等的实现。- 写入 err 和 cause。
- 关闭 done channel。没人调用过
Done()时,done 为 nil,直接存一个包级共享的已关闭 channel(closedchan),省一次分配。 - 遍历 children,逐个调用子 context 的 cancel。
- children 置 nil。
- 需要时把自己从父的 children 里移除。
propagateCancel:取消怎么传播
WithCancel 创建子 context 时调用 propagateCancel(parent, child),把子挂到父上。按顺序四条分支:
- 父的
Done()返回 nil(父不可取消):什么都不做,子永远不会被父取消。 - 父的 Done 已关闭(父已经取消):子立即取消,err 和 cause 继承父的。
- 父是标准库 cancelCtx(通过
parentCancelCtx找到):直接把子加进父的 children map。父取消时遍历取消所有子。 - 父是自定义 context 实现:起一个 goroutine,
select父的 Done 和子的 Done,谁先关闭谁退出,父先关闭就取消子。
parentCancelCtx 靠 parent.Value(&cancelCtxKey) 找内层 cancelCtx:valueCtx 的 Value 委托给父,cancelCtx 的 Value 对自己返回自身,所以从任意派生链上都能找到最内层的 cancelCtx。找不到(比如父是自定义实现包了别的 Done channel)才走 goroutine 路径。goroutine 在子或父任一侧 Done 关闭后退出;两侧都不关闭,goroutine 就泄漏——这就是「必须调用 cancel」的底层原因之一。
父子关系一共就这几条:
| 场景 | 行为 |
|---|---|
| 父取消 | 遍历 children,逐个取消子,err/cause 继承父 |
| 子取消 | 只把自己从父的 children 摘除,父和兄弟不受影响 |
| 创建时父已取消 | 子立即取消,不挂进 children |
| 创建时父不可取消 | 不建立任何关系,子独立 |
| 父取消后新挂的子 | 走「父已取消」分支立即取消,children 已置 nil,不会再挂上 |
机制上三点:
- children 只挂在 cancelCtx 上(timerCtx 内嵌 cancelCtx,也算)。valueCtx 不持有 children,作为父时被
parentCancelCtx穿透,子实际挂在最内层 cancelCtx 上。 - 取消向下传播时子调用
cancel(false, ...),不做摘除动作;父遍历完把 children 置 nil,整棵子树的关系一次清空。主动cancel()才走cancel(true, ...),removeChild把自己从父摘除。 - 子被父取消时用的是父的 err 和 cause(
child.cancel(false, parent.Err(), Cause(parent))),所以父子Err()一致,Cause()也沿链一致。
timerCtx:WithDeadline 的载体
type timerCtx struct {
cancelCtx
timer *time.Timer // 受 cancelCtx.mu 保护
deadline time.Time
}内嵌 cancelCtx,加一个定时器和一个 deadline:
WithDeadline用time.AfterFunc(dur, cancel)启动定时器,到点自动取消。- deadline 已过(
dur <= 0)不启动定时器,直接取消。 - 手动 cancel 时先
timer.Stop()停掉定时器再走 cancelCtx 的取消流程。所以defer cancel()不只是取消信号,还负责停定时器。
WithDeadline 的完整流程:
func WithDeadlineCause(parent Context, d time.Time, cause error) (Context, CancelFunc) {
if cur, ok := parent.Deadline(); ok && cur.Before(d) {
return WithCancel(parent) // 父的 deadline 更早,退化成普通取消
}
c := &timerCtx{deadline: d}
c.cancelCtx.propagateCancel(parent, c) // 先挂到父上
dur := time.Until(d)
if dur <= 0 {
c.cancel(true, DeadlineExceeded, cause) // deadline 已过,立即取消
return c, func() { c.cancel(false, Canceled, nil) }
}
c.mu.Lock()
defer c.mu.Unlock()
if c.err.Load() == nil {
c.timer = time.AfterFunc(dur, func() {
c.cancel(true, DeadlineExceeded, cause)
})
}
return c, func() { c.cancel(true, Canceled, nil) }
}定时器在锁内、确认 err 为 nil 后才启动:propagateCancel 之后父可能已经取消,这时再启动定时器就是白分配,回调 fire 时也只会撞上「已取消」的幂等分支。
timerCtx.cancel 覆盖了内嵌 cancelCtx 的 cancel:
func (c *timerCtx) cancel(removeFromParent bool, err, cause error) {
c.cancelCtx.cancel(false, err, cause) // 先广播取消(关 done、逐个取消 children)
if removeFromParent {
removeChild(c.cancelCtx.Context, c) // 再从父的 children 摘除
}
c.mu.Lock()
if c.timer != nil {
c.timer.Stop() // 最后停定时器,释放资源
c.timer = nil
}
c.mu.Unlock()
}手动 cancel 传 Canceled,超时回调传 DeadlineExceeded,同一个取消流程,两种错误来源。timerCtx 只重写 Deadline()(直接返回存的 deadline)和 cancel(),Done()/Err() 全部复用内嵌的 cancelCtx。
valueCtx:WithValue 的载体
type valueCtx struct {
Context
key, val any
}只存一对 key-value,其他方法全部委托给内嵌的父 context。Value() 本层 key 不匹配就往上找,这就是查找沿链向上的实现。
WithValue 的三个 panic 检查:parent 为 nil、key 为 nil、key 不可比较(reflectlite.TypeOf(key).Comparable())。key 的不可比较检查是运行时才做的,所以传 slice、map 这类 key 会在调用 WithValue 时直接 panic,而不是等到 Value() 查找时。
Value() 的查找不是递归,是循环 + 类型断言(value() 函数):
func value(c Context, key any) any {
for {
switch ctx := c.(type) {
case *valueCtx:
if key == ctx.key {
return ctx.val
}
c = ctx.Context
case *cancelCtx:
if key == &cancelCtxKey {
return c
}
c = ctx.Context
case *timerCtx:
if key == &cancelCtxKey {
return &ctx.cancelCtx
}
c = ctx.Context
case backgroundCtx, todoCtx:
return nil
default:
return c.Value(key)
}
}
}几个细节:
- 循环代替递归,链再长也不会有栈增长。
- 对
cancelCtxKey的命中是特判:cancelCtx 返回自身、timerCtx 返回内嵌的 cancelCtx,这是parentCancelCtx能穿透 valueCtx 找到内层 cancelCtx 的机制(见 propagateCancel 一节)。 - 走到 backgroundCtx/todoCtx 返回 nil,链到头了。
- 遇到不认识的自定义 context 类型,退回调用它的
Value(key),保证自定义实现的行为不被破坏。
Go 1.21 起的补充
WithCancelCause/Cause:cancel 时带一个错误作为取消原因,Cause(ctx)取回。父先取消时子继承父的 cause。AfterFunc:context 取消时调度一个函数执行,返回的 stop 函数可以在取消前撤销注册。WithoutCancel:派生出不受父取消影响的 context,Done 返回 nil,Err 永远返回 nil,常用于日志、后台清理这类「父死了也要干完」的场景。
内存模型
Done channel 的关闭遵守 channel 关闭规则:关闭 happens-before 任何观察到关闭的接收。所以 cancel 里对共享数据的写入,对 <-ctx.Done() 返回之后的 goroutine 可见——取消信号本身就携带同步语义,不需要额外加锁。
两个细节:
- 文档注明 Done 的关闭可能异步发生:cancel 函数返回后,channel 才被关闭。
Err()返回非 nil 前会先等 Done 关闭(源码里<-c.Done()),保证「Err 非 nil」和「Done 已关闭」严格一致。
常见模式
请求级 context
HTTP 服务里,每个请求的 context 从入口一路传下去,下游函数都收 ctx 参数。客户端断开、超时、服务端主动取消,整条调用链一起停。
func handler(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
result, err := doWork(ctx)
// ...
}
func doWork(ctx context.Context) (Result, error) {
select {
case <-ctx.Done():
return Result{}, ctx.Err()
case result := <-workCh:
return result, nil
}
}慢操作包一层超时
调用方无法控制的下游操作,用 WithTimeout 包一层,防止无限等待:
ctx, cancel := context.WithTimeout(r.Context(), 100*time.Millisecond)
defer cancel()
result, err := slowService(ctx)注意 defer cancel():慢操作提前返回时立刻停掉定时器,释放资源。
陷阱清单
- 忘记调用 cancel:定时器、传播 goroutine 不释放。拿到 cancel 立刻 defer。
- 把 context 存进 struct 字段:官方明确禁止,应该作为参数传递。
- 用 context 传函数参数:WithValue 的 key 不是类型安全的,取出来要断言,参数应当走正常入参。
- 传 nil context:
WithCancel(nil)、WithValue(nil, ...)直接 panic。拿不准传context.TODO()。 - goroutine 里用父 context 而不派生:goroutine 应该用
context.WithCancel(parent)派生的 context,父取消时 goroutine 里的 select 才能响应退出。 - 把 cancel 函数传出去用:cancel 应该由创建它的函数调用,
defer cancel()是标准写法,不要传给别的 goroutine 乱调。
面试题
Context 是什么,解决了什么问题?
答:跨 API 边界传递截止时间、取消信号和请求域数据的标准机制。解决的问题是并发程序的取消和超时:多个 goroutine 协作时,一个请求失败或超时,需要有一条链把「停下来」的信号传到所有相关 goroutine,而不是靠手动维护一堆 channel 或共享标志位。
Context 接口有哪几个方法?
答:四个。Deadline() 返回截止时间;Done() 返回取消信号 channel;Err() 返回取消原因;Value(key) 取请求域数据。
Background 和 TODO 的区别?
答:功能上完全一样,都是不可取消的空 context,区别在语义。Background 是正式使用的根;TODO 是还没决定用哪个 context 时的占位符,提醒后面要替换。
父 context 取消后,子 context 会怎样?
答:子 context 一起取消,Done 关闭,Err 返回父的取消原因。反过来,子取消不影响父。这是取消传播:从根到叶单向传导。
不调用 cancel 会有什么后果?
答:资源泄漏。WithTimeout/WithDeadline 的定时器不停,等不到超时那一刻不释放;父是不可取消的自定义 context 时,传播用的 goroutine 也留着。所以规范是拿到 cancel 立即 defer cancel(),保证任何返回路径都释放。
WithTimeout 和 WithDeadline 是什么关系?
答:WithTimeout(parent, d) 是 WithDeadline(parent, time.Now().Add(d)) 的包装。底层都是 timerCtx:一个定时器到点触发取消。区别只是参数形式:时长还是具体时刻。
Err() 什么时候返回 Canceled,什么时候返回 DeadlineExceeded?
答:主动调用 cancel 返回 context.Canceled;超时或 deadline 到点自动取消返回 context.DeadlineExceeded。两者都是 error 类型,可以用 errors.Is 判断。
context 的值查找是怎么实现的?
答:valueCtx 只存一对 key-value,Value() 本层找不到就委托父 context 继续找,沿链向上直到根或命中。cancelCtx 对内部 key(cancelCtxKey)返回自身,用于取消传播定位。key 必须可比较且不能是内置类型,避免冲突。
如何让 goroutine 响应取消?
答:goroutine 里用派生的 context,在 select 里监听 <-ctx.Done(),收到信号后做清理并退出(写法见上文 doWork)。配合 WithTimeout,还能给 goroutine 加超时上限。
CPU 密集的循环任务没有阻塞点,每步之间轮询检查:
for {
select {
case <-ctx.Done():
return ctx.Err()
default:
}
step()
}阻塞调用优先把 ctx 传给库,http、database/sql、net 这些库内部已经监听 Done,取消时立即返回错误:
req, _ := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
db.QueryContext(ctx, ...)旧库不接受 ctx 时,用 goroutine 包装加 select 兜底,但底层调用不返回的话 goroutine 会泄漏,能换支持 ctx 的库就换。
context 能存进 struct 吗?
答:不能。官方规定 context 必须作为参数显式传递,第一个参数,通常命名 ctx。存 struct 会破坏取消信号的传播路径,静态分析工具也没法检查传递是否完整。