第 14 章:Context、取消传播与生命周期管理
从 Context 接口语义、取消树、WithCancel/WithTimeout/WithDeadline、Cause 系列 API、AfterFunc、WithoutCancel,到 HTTP、DB、RPC、Worker Pool、Pipeline 的生命周期管理与泄漏排查,系统梳理 Go Context 的面试知识链。
第 14 章:Context、取消传播与生命周期管理
版本口径:截至 2026-06-19,Go 官方 Release History 显示当前稳定大版本线为 Go 1.26,最新补丁版为 Go 1.26.4,发布于 2026-06-02。本章源码口径以
go1.26.4为准。(Go) 核心资料:context包官方文档明确说明:Context 用于跨 API 边界传递 deadline、取消信号、request-scoped values;派生 Context 会形成取消树;CancelFunc必须调用,否则子 Context 及其 children 会被父 Context 持有直到父取消。(Go Packages)
阅读定位与关联章节
本章主讲 Context 接口语义、取消树、deadline/timeout、Cause、AfterFunc、WithoutCancel、跨 API 生命周期、Worker/Pipeline 取消和泄漏治理。它不是同步原语章节,也不是 Goroutine 调度章节;Context 的价值在于把生命周期所有权显式传下去。
| 关联概念 | 建议读法 |
|---|---|
| Goroutine 泄漏、wait reason、调度延迟与 profile | 本章讲生命周期收束;GMP、调度状态和 goroutine profile 分析看 第 11 章:并发基础、Goroutine 生命周期与 Go 内存模型。 |
| Pipeline、done Channel、关闭所有权 | Context 取消在本章;Channel 通信和关闭协议看 第 12 章:Channel。 |
| WaitGroup、Mutex、atomic 与取消的同步边界 | 本章讲取消信号;WaitGroup/Mutex 看 第 13 章:Mutex、RWMutex 与 sync 工具箱,Atomic 看 第 15 章:Atomic、CAS、内存语义与无锁思想。 |
| Cause、错误包装、panic 与 worker 边界 | 本章讲取消原因;errors 包装、panic/recover 和错误边界看 第 2 章:函数、defer、panic/recover 与 errors。 |
| timer、deadline、子 Context 未 cancel 导致保留 | 本章讲调用契约;对象保留、GC 和内存排查看 第 6 章:内存管理、逃逸分析与 GC。 |
本章速览
先把本章看成一条从“取消信号”到“跨 API 生命周期管理”的传播链:

读图时抓住三个总结:
- Context 传递的是取消信号、deadline 和请求域值,不会强杀 goroutine,也不会替你等待退出。
- 派生 Context 会形成取消树,
CancelFunc不调用就可能让父节点继续持有子节点。 - 好的生命周期设计要把超时预算、错误原因、等待收束和资源释放一起考虑。
面试题目精选
本章原有题库很完整,这里把最常被追问的题提前列出来:
context.Context解决的核心问题是什么?为什么它不是 Goroutine 管理器?- 为什么 Context 通常作为函数第一个参数?为什么不要传
nil? Done()、Err()、Deadline()、Value()分别表达什么契约?WithCancel、WithTimeout、WithDeadline的取消传播规则是什么?- 为什么
CancelFunc必须调用?不调用会保留哪些对象? - 父取消、子取消、deadline 更早者分别如何影响取消树?
cancel()会不会等待 Goroutine 退出?如何组合 WaitGroup/errgroup?WithCancelCause、Cause和Err的关系是什么?第一次取消获胜是什么意思?AfterFunc的 stop 竞态如何理解?WithoutCancel为什么Done()是 nil?- 为什么不建议把业务参数塞进
Context.Value?哪些 value 可以放? - HTTP、DB、RPC、Worker Pool、Pipeline 中如何传递 Context 才不会泄漏?
- 线上大量
context deadline exceeded,如何判断是 DB 慢、连接池耗尽,还是 Context 没传递?
一、本章面试目标
本章要建立的知识链是:
Context 解决什么问题
→ Context 接口语义
→ Background / TODO / nil Context
→ WithCancel / WithTimeout / WithDeadline
→ Cause 系列 API
→ AfterFunc / WithoutCancel
→ 取消树与父子传播
→ context.go 源码结构
→ HTTP / DB / RPC / Worker Pool / Pipeline 实战
→ 超时预算与泄漏问题
→ pprof / trace / go vet / race 排查
→ 面试深挖表达
1. 初级面试需要掌握
初级至少要能答清楚:
context.Context是什么;- 为什么通常作为函数第一个参数;
Done()、Err()、Deadline()、Value()分别干什么;WithCancel、WithTimeout、WithDeadline怎么用;- 为什么
defer cancel(); - 为什么不能传
nil Context; - 为什么不要把业务参数塞进
Context.Value。
2. 中高级面试需要掌握
中高级要能进一步解释:
- Context 是 取消信号传播机制,不是 goroutine 管理器;
cancel()不会强杀 goroutine,也不会等待 goroutine 退出;- 父取消会取消子,子取消不会取消父;
- 父子 deadline 取更早者;
Value是链式查找,过度使用会增加复杂度和耦合;CancelFunc不调用会导致 parent 持有 child,形成资源泄漏;- HTTP、
database/sql、RPC、worker pool、pipeline 如何传递 Context; - 超时风暴、层层 timeout、deadline 预算如何设计;
errgroup.WithContext如何把第一个错误转成取消信号。
3. 高级 / 源码级面试可能追问
高级会追问:
emptyCtx、valueCtx、cancelCtx、timerCtx、afterFuncCtx、stopCtx的关系;cancelCtx.children map[canceler]struct{}的作用;donechannel 为什么延迟创建;cancelCtx.err和done为什么用atomic.Value;propagateCancel如何减少额外 goroutine;- 自定义 Context 为什么可能触发 fallback goroutine;
AfterFunc的 stop 竞态;WithoutCancel为什么Done()是nil;Cause的第一次取消获胜规则;testing/synctest如何测试超时和并发取消。
二、功能介绍与语言语义
1. Context 解决的问题
在服务端系统里,一个请求进来后,通常会派生多个下游操作:
HTTP 请求
├── 鉴权
├── 查 Redis
├── 查 DB
├── 调 RPC
├── 发 MQ
└── 聚合结果
如果客户端断开、请求超时、上游取消,所有相关工作都应该尽快停止,否则会造成:
- goroutine 泄漏;
- DB 查询继续跑;
- RPC 请求继续占连接;
- worker pool 任务堆积;
- CPU 无意义消耗;
- 请求已经失败但后台还在发送结果。
Context 的定位是:
把一次请求或一次任务的生命周期信号,沿调用链显式传下去。
官方文档也强调,传入请求应创建 Context,出站调用应接收 Context,中间调用链必须传播 Context。(Go Packages)
2. Context 接口
context.Context 是一个接口:
type Context interface {
Deadline() (deadline time.Time, ok bool)
Done() <-chan struct{}
Err() error
Value(key any) any
}
Deadline()
表示这个 Context 最晚什么时候应该取消。
deadline, ok := ctx.Deadline()
ok == false:没有 deadline;ok == true:到达 deadline 后,Done()会关闭,Err()返回context.DeadlineExceeded;- deadline 可以用于判断是否还有必要开始一个昂贵操作。
Done()
返回一个只读 channel:
select {
case <-ctx.Done():
return ctx.Err()
case result := <-work:
return result
}
语义:
- Context 被取消或超时后,
Done()被关闭; Done()可能返回 nil,例如context.Background()、context.TODO()、context.WithoutCancel(parent);- 对 nil channel 接收会永久阻塞,所以生产代码要用
select,不要裸<-ctx.Done()。
官方源码注释明确说明:Done may return nil if this context can never be canceled,并且 Done 的关闭可能在 cancel 返回后异步发生。(GitHub)
Err()
返回取消原因:
err := ctx.Err()
常见结果:
| 状态 | ctx.Err() |
|---|---|
| 未取消 | nil |
| 主动取消 | context.Canceled |
| deadline 超时 | context.DeadlineExceeded |
注意:
Err()只能告诉你 Context 已取消;- 不能靠
Err()阻止之后发生竞态; Err()返回非 nil 后,后续调用结果稳定。
Value(key any) any
用于传递跨 API 边界的 request-scoped 数据,例如:
- trace id;
- request id;
- auth principal;
- tenant id;
- logger;
- OpenTelemetry span context。
不适合放:
- 业务参数;
- 可选参数;
- DB 连接;
- 大对象;
- 可变且非线程安全对象;
- 全局配置;
- 长生命周期资源。
官方文档明确说:Context values 只应用于跨进程和 API 边界传播的 request-scoped data,不应用于传递函数可选参数。(Go Packages)
3. Background、TODO、nil Context
context.Background()
ctx := context.Background()
特点:
- 非 nil;
- 永不取消;
- 无 deadline;
- 无 value;
- 通常用于
main、初始化、测试、顶层请求入口。
context.TODO()
ctx := context.TODO()
语义:
- 非 nil;
- 永不取消;
- 无 deadline;
- 无 value;
- 表示“这里应该有 Context,但当前还不知道传什么”。
面试表达:
TODO()不是生产语义,只是迁移或占位信号。能传上游 Context 时,不应该用TODO()或Background()替代。
nil Context
错误写法:
ctx, cancel := context.WithCancel(nil)
结果:
panic: cannot create context from nil parent
官方文档明确建议:不要传 nil Context,即使函数允许,也应该传 context.TODO()。(Go Packages)
4. 常用派生函数
WithCancel
ctx, cancel := context.WithCancel(parent)
defer cancel()
作用:
- 创建一个可取消的子 Context;
cancel()取消子和子孙;- 父取消也会取消子;
- 子取消不会取消父。
WithDeadline
ctx, cancel := context.WithDeadline(parent, time.Now().Add(100*time.Millisecond))
defer cancel()
作用:
- 到达指定时间自动取消;
- 如果父 Context 的 deadline 更早,则子 Context 使用更早 deadline;
- 返回的
cancel()仍然要调用,用于释放 timer 和从父 children 中移除自己。
WithTimeout
ctx, cancel := context.WithTimeout(parent, 100*time.Millisecond)
defer cancel()
本质上是:
WithDeadline(parent, time.Now().Add(timeout))
源码中 WithTimeout 直接调用 WithDeadline(parent, time.Now().Add(timeout))。(GitHub)
5. Cause 系列 API
Go 1.20 增加了取消原因 API:
ctx, cancel := context.WithCancelCause(parent)
cancel(errors.New("quota exceeded"))
fmt.Println(ctx.Err()) // context canceled
fmt.Println(context.Cause(ctx)) // quota exceeded
相关 API:
context.WithCancelCause
context.WithDeadlineCause
context.WithTimeoutCause
context.Cause
关键规则:
ctx.Err()仍然返回标准错误:Canceled或DeadlineExceeded;context.Cause(ctx)返回更具体的业务原因;- 第一次取消决定 Cause;
- 后续 cancel 不会覆盖 cause;
- 如果没有设置 cause,
Cause(ctx)返回和ctx.Err()相同的值。
官方文档说明 WithCancelCause added in Go 1.20,并且 cancel(nil) 会把 cause 设置为 Canceled。(Go Packages)
6. AfterFunc
Go 1.21 增加了:
stop := context.AfterFunc(ctx, func() {
cleanup()
})
defer stop()
语义:
- ctx 取消后,在独立 goroutine 中执行
f; - 如果 ctx 已经取消,则立即在新 goroutine 中执行;
- 多次
AfterFunc相互独立; stop()只表示是否成功阻止f启动;stop()不等待 f 执行完成。
官方文档明确:AfterFunc added in Go 1.21,stop 返回 false 时可能表示函数已经启动或已经被停止,并且不会等待函数完成。(Go Packages)
典型用途:
- context 取消时唤醒
sync.Cond; - 取消阻塞在非 Context-aware 操作上的等待;
- 超时后关闭连接;
- cleanup hook。
7. WithoutCancel
Go 1.21 增加了:
ctx2 := context.WithoutCancel(ctx)
语义:
- 保留 parent 的 Value;
- 不继承 parent 的取消;
- 不继承 parent 的 deadline;
Done()返回 nil;Err()返回 nil;Cause(ctx2)返回 nil。
官方文档明确说明 WithoutCancel 返回的 Context 不会因 parent 取消而取消,且没有 deadline、没有 Err、Done 为 nil。(Go Packages)
适用场景:
- HTTP handler 返回前,需要派生一个短后台任务继续写审计日志;
- 请求取消后仍需做 best-effort cleanup;
- 需要保留 trace/request value,但不能被原请求取消。
危险点:
select {
case <-ctx2.Done():
// 永远不会走
}
如果没有其他 case,这会永久阻塞。
8. Context 为什么通常作为第一个参数
推荐风格:
func QueryUser(ctx context.Context, id int64) (*User, error)
原因:
- 调用者清楚这个函数支持取消和超时;
- 调用链可以统一传播;
go vet等静态工具更容易检查;- 不和业务参数混在一起;
- API 语义清晰。
官方文档明确建议 Context 应是第一个参数,通常命名为 ctx。(Go Packages)
9. 为什么通常不要存进 Struct
不推荐:
type Service struct {
ctx context.Context
}
原因:
- 混淆生命周期;
- 一个
Service可能服务多个请求,但ctx只代表某一次请求; - 调用者无法为每次调用设置独立 deadline;
- 容易把已经取消的 ctx 复用到新请求;
- 容易导致隐藏依赖。
Go Blog 专门讨论过 “Contexts and structs”,其中指出把 Context 存进 struct 会混淆 lifetime,导致调用者不能按每次调用设置 deadline、取消和 metadata。(Go)
少数例外:
- 为保持 Go 1.7 前老 API 的兼容性;
- 某些对象本身就代表一次长生命周期操作;
- 结构体字段清楚标注生命周期,并且不跨请求复用。
面试回答:
原则是不存。例外不是没有,但要证明这个 struct 的生命周期和 ctx 生命周期一致,否则就是 API 设计问题。
三、底层实现
context不是 Go 语言规范的特殊能力,而是标准库包。核心源码在src/context/context.go。Go 1.26.4 源码中,cancelCtx使用sync.Mutex、atomic.Value、children map、懒创建 done channel、timer 等机制实现取消传播。(GitHub)
1. 类型总览
源码核心类型:
type emptyCtx struct{}
type backgroundCtx struct {
emptyCtx
}
type todoCtx struct {
emptyCtx
}
type valueCtx struct {
Context
key, val any
}
type cancelCtx struct {
Context
mu sync.Mutex
done atomic.Value
children map[canceler]struct{}
err atomic.Value
cause error
}
type timerCtx struct {
cancelCtx
timer *time.Timer
deadline time.Time
}
type afterFuncCtx struct {
cancelCtx
once sync.Once
f func()
}
type stopCtx struct {
Context
stop func() bool
}
关系图:
Context interface
|
+-- emptyCtx
| |
| +-- backgroundCtx
| +-- todoCtx
|
+-- valueCtx
|
+-- cancelCtx
| |
| +-- timerCtx
| +-- afterFuncCtx
|
+-- withoutCancelCtx
2. emptyCtx、backgroundCtx、todoCtx
emptyCtx 的行为:
func (emptyCtx) Deadline() (time.Time, bool) { return }
func (emptyCtx) Done() <-chan struct{} { return nil }
func (emptyCtx) Err() error { return nil }
func (emptyCtx) Value(key any) any { return nil }
所以:
context.Background().Done() == nil
context.TODO().Done() == nil
面试重点:
Background和TODO都不可取消,区别主要是表达意图,不是运行时能力差异。
3. valueCtx:链式 Value 查找
每次 WithValue 都包装一层:
ctx = context.WithValue(ctx, k1, v1)
ctx = context.WithValue(ctx, k2, v2)
ctx = context.WithValue(ctx, k3, v3)
结构类似:
valueCtx(k3, v3)
↓ parent
valueCtx(k2, v2)
↓ parent
valueCtx(k1, v1)
↓ parent
backgroundCtx
查找:
ctx.Value(k1)
会从最近一层往 parent 链查。
复杂度:
O(depth)
面试重点:
Context Value 不是 map。它是链式包装。少量 request-scoped metadata 可以,不能把它当通用参数袋。
4. cancelCtx:取消树核心
cancelCtx 是取消传播的核心。
关键字段:
type cancelCtx struct {
Context
mu sync.Mutex
done atomic.Value
children map[canceler]struct{}
err atomic.Value
cause error
}
字段含义:
| 字段 | 作用 |
|---|---|
Context | parent |
mu | 保护 children、cause、done 创建等 |
done | 懒创建 channel,取消时 close |
children | 直接子节点集合 |
err | 第一次取消错误 |
cause | 第一次取消原因 |
取消树:
parent cancelCtx
children:
├── child cancelCtx
├── child timerCtx
└── child afterFuncCtx
父取消时:
parent.cancel()
├── close(parent.done)
├── parent.err = context.Canceled / DeadlineExceeded
├── parent.cause = cause
├── for child := range children:
│ child.cancel(...)
└── children = nil
5. done channel 为什么懒创建
不是每个 Context 都会被 select:
ctx, cancel := context.WithCancel(parent)
defer cancel()
// 可能只传给下游,但本层不调用 ctx.Done()
如果创建 Context 时马上创建 channel,会增加分配。
所以源码里:
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{})
}
取消时,如果 done 还没创建,会存一个全局已关闭 channel:
var closedchan = make(chan struct{})
func init() {
close(closedchan)
}
这能避免为从未调用 Done() 的 Context 单独分配 channel。
6. 为什么用 atomic.Value
cancelCtx 中:
done atomic.Value
err atomic.Value
原因:
Done()、Err()会被频繁调用;- 取消通常只发生一次;
- 快路径读用 atomic 比每次加锁更便宜;
- 写路径仍由 mutex 协调。
源码注释中提到,Err() 的 atomic load 比 mutex 快很多,紧循环里可能有意义。(GitHub)
7. propagateCancel:取消传播如何建立
伪流程:
func (c *cancelCtx) propagateCancel(parent Context, child canceler) {
c.Context = parent
done := parent.Done()
if done == nil {
return
}
select {
case <-done:
child.cancel(false, parent.Err(), Cause(parent))
return
default:
}
if p, ok := parentCancelCtx(parent); ok {
p.children[child] = struct{}{}
return
}
if a, ok := parent.(afterFuncer); ok {
stop := a.AfterFunc(func() {
child.cancel(false, parent.Err(), Cause(parent))
})
c.Context = stopCtx{Context: parent, stop: stop}
return
}
go func() {
select {
case <-parent.Done():
child.cancel(false, parent.Err(), Cause(parent))
case <-child.Done():
}
}()
}
源码级重点:
- parent 不可取消:直接返回,不创建 goroutine;
- parent 已取消:立即取消 child;
- parent 是标准
cancelCtx派生:把 child 放进 parent.children; - parent 实现
AfterFunc:注册回调,不额外常驻 goroutine; - 自定义 parent 无法识别:fallback goroutine 等待 parent 或 child。
面试表达:
Context 取消树不是每创建一个 child 就一定开 goroutine。标准 Context 派生主要靠 parent.children map 传播;只有无法挂到标准 cancelCtx 或 AfterFunc 的自定义 Context,才会退化成额外 goroutine。
8. timerCtx:deadline / timeout 的实现
timerCtx:
type timerCtx struct {
cancelCtx
timer *time.Timer
deadline time.Time
}
创建流程:
WithTimeout
→ WithDeadline(parent, now + timeout)
→ WithDeadlineCause
→ 如果 parent deadline 更早:返回 WithCancel(parent)
→ 创建 timerCtx
→ propagateCancel(parent, timerCtx)
→ time.AfterFunc(dur, func() {
c.cancel(true, DeadlineExceeded, cause)
})
关键点:
- timeout 是 timer 驱动;
- 提前
cancel()会 stop timer; - timer 到期会触发
DeadlineExceeded; - 如果父 deadline 更早,不会创建一个更晚的 timer。
9. cancel() 为什么要调用
官方文档明确说:调用 CancelFunc 会取消 child 和 child 的 children,从 parent 中移除 child,并停止 timer;不调用会泄漏 child 和 children,直到 parent 被取消。(Go Packages)
泄漏不是说 goroutine 一定泄漏,而是:
parent.children 持有 child
child 可能持有 timer
child 可能持有 value
child 可能持有 children
典型错误:
func query(parent context.Context) error {
ctx, _ := context.WithTimeout(parent, time.Second)
return do(ctx)
}
正确:
func query(parent context.Context) error {
ctx, cancel := context.WithTimeout(parent, time.Second)
defer cancel()
return do(ctx)
}
10. Cause 第一次取消获胜
示例:
parent, cancelParent := context.WithCancelCause(context.Background())
child, cancelChild := context.WithCancelCause(parent)
cancelParent(errors.New("parent failed"))
cancelChild(errors.New("child failed"))
fmt.Println(context.Cause(child))
输出:
parent failed
因为 parent 先取消,child 被 parent 取消,后续 child 自己的 cancel 不再覆盖 cause。
反过来:
cancelChild(errors.New("child failed"))
cancelParent(errors.New("parent failed"))
child 的 cause 是 child failed,parent 的 cause 是 parent failed。
源码中 cancelCtx.cancel 会先判断 c.err.Load() != nil,如果已经取消,直接返回。(GitHub)
11. happens-before 与并发语义
Done() 是 channel close 信号。
在 Go 内存模型中,channel close 与接收有同步关系。对 Context 的使用可以理解为:
cancel()
close(done)
<-ctx.Done()
observe cancellation
但注意:
- Context 只同步取消事件;
- 不会保护你放进
Value的对象; - 不会自动让业务 goroutine 停止;
- 不会替代 mutex、atomic、channel 协议。
错误理解:
// 错误:以为 ctx 取消后,map 并发写就安全了
m := ctx.Value(key).(map[string]string)
go func() { m["a"] = "1" }()
go func() { m["b"] = "2" }()
这仍然是 data race。
四、源码阅读路径
1. 主路径
核心文件:
src/context/context.go
推荐阅读顺序:
Context interface
→ emptyCtx / backgroundCtx / todoCtx
→ WithCancel / withCancel
→ cancelCtx
→ cancelCtx.Done / Err / cancel
→ propagateCancel
→ parentCancelCtx / removeChild
→ timerCtx / WithDeadline / WithTimeout
→ WithCancelCause / Cause
→ AfterFunc / afterFuncCtx / stopCtx
→ WithoutCancel
→ WithValue / valueCtx / value()
2. 重点类型
Context
emptyCtx
backgroundCtx
todoCtx
valueCtx
cancelCtx
timerCtx
afterFuncCtx
stopCtx
canceler
afterFuncer
withoutCancelCtx
3. 重点函数
Background
TODO
WithCancel
WithCancelCause
Cause
AfterFunc
WithoutCancel
WithDeadline
WithDeadlineCause
WithTimeout
WithTimeoutCause
WithValue
withCancel
propagateCancel
parentCancelCtx
removeChild
cancelCtx.cancel
cancelCtx.Done
cancelCtx.Err
value
4. 关键调用链
WithCancel
WithCancel(parent)
→ withCancel(parent)
→ panic if parent == nil
→ c := &cancelCtx{}
→ c.propagateCancel(parent, c)
→ return c, func(){ c.cancel(true, Canceled, nil) }
WithTimeout
WithTimeout(parent, timeout)
→ WithDeadline(parent, time.Now().Add(timeout))
→ WithDeadlineCause(parent, d, nil)
→ parent deadline earlier?
→ WithCancel(parent)
→ create timerCtx
→ propagateCancel
→ time.AfterFunc
父取消传播
parent.cancel()
→ close(parent.done)
→ for child := range parent.children:
child.cancel(false, parent.err, parent.cause)
5. 其他相关源码路径
go vet lostcancel
src/cmd/vet/main.go
src/cmd/vendor/golang.org/x/tools/go/analysis/passes/lostcancel/lostcancel.go
cmd/vet 文档列出了 lostcancel 检查:检查 context.WithCancel 返回的 cancel func 是否被调用。(Go Packages)
HTTP Request Context
src/net/http/request.go
src/net/http/server.go
src/net/http/client.go
src/net/http/transport.go
官方 net/http 文档说明:
Request.Context()总是非 nil;- client request 的 Context 控制取消;
- server request 的 Context 会在客户端连接关闭、HTTP/2 request cancel、或
ServeHTTP返回时取消。(Go Packages)
database/sql Context
src/database/sql/sql.go
database/sql 提供 ExecContext、QueryContext、PingContext、BeginTx 等 Context-aware API;非 Context 版本内部使用 context.Background(),要指定取消应使用 Context 版本。(Go Packages)
errgroup
golang.org/x/sync/errgroup
errgroup.WithContext 返回一个派生 Context:第一个 goroutine 返回非 nil error 或第一次 Wait 返回时,会取消该 Context。(Go Packages)
testing/synctest
src/testing/synctest/synctest.go
Go 1.26.4 中 testing/synctest 是标准库包,Test added in Go 1.25.0;它在隔离 bubble 中测试并发代码,bubble 内 time 使用 fake clock,Wait 等待其他 goroutine durably blocked。(Go Packages)
五、常用场景与工程取舍
场景 1:HTTP 请求链路
func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
user, err := h.userService.GetUser(ctx, userID)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
_ = user
}
适合原因:
- 客户端断开后,下游 DB / RPC 可以尽快取消;
- 请求超时可以沿调用链传递;
- trace id、auth principal 可以放 Context Value。
不适合:
- 不应该把业务参数全塞进 Context;
- 不应该用
context.Background()替换r.Context()。
场景 2:database/sql 查询
正确:
ctx, cancel := context.WithTimeout(r.Context(), 200*time.Millisecond)
defer cancel()
rows, err := db.QueryContext(ctx, "select * from users where id = ?", id)
错误:
rows, err := db.Query("select * from users where id = ?", id)
问题:
Query内部相当于用Background;- 上游取消无法传播;
- 慢查询继续占连接;
- 连接池耗尽时服务雪崩。
场景 3:RPC 调用
ctx, cancel := context.WithTimeout(parent, 100*time.Millisecond)
defer cancel()
resp, err := client.GetProfile(ctx, req)
取舍:
- 每层都设 timeout 容易 timeout storm;
- 更推荐顶层设置总 deadline,下游按剩余时间分配预算;
- 需要区分业务错误和
context.DeadlineExceeded。
场景 4:Worker Pool
func worker(ctx context.Context, jobs <-chan Job) {
for {
select {
case <-ctx.Done():
return
case job, ok := <-jobs:
if !ok {
return
}
process(ctx, job)
}
}
}
适合:
- 关闭服务时通知 worker 退出;
- request-scoped job 可以取消;
- 避免 job channel 永久等待。
坑:
process内部也必须响应 ctx;- 只在 worker 外层 select 不够;
- CPU 密集循环需要定期检查
ctx.Done()。
场景 5:Pipeline
func stage(ctx context.Context, in <-chan int) <-chan int {
out := make(chan int)
go func() {
defer close(out)
for {
select {
case <-ctx.Done():
return
case v, ok := <-in:
if !ok {
return
}
select {
case <-ctx.Done():
return
case out <- v * 2:
}
}
}
}()
return out
}
重点:
- 接收要响应取消;
- 发送也要响应取消;
- 否则下游退出后,上游可能卡在发送上。
Go Blog 的 Pipelines and cancellation 也强调,pipeline 下游提前退出时,上游发送者可能阻塞,必须显式处理取消。(Go)
场景 6:Graceful Shutdown
srv := &http.Server{Addr: ":8080"}
go srv.ListenAndServe()
stopCtx, stop := signal.NotifyContext(context.Background(), os.Interrupt)
defer stop()
<-stopCtx.Done()
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
_ = srv.Shutdown(ctx)
关键:
- shutdown ctx 是服务关闭预算;
- 不要用已经取消的 request ctx 去做 shutdown;
- shutdown 超时后应强制退出或记录未完成任务。
场景 7:异步任务和 WithoutCancel
func handler(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
bg := context.WithoutCancel(ctx)
bg, cancel := context.WithTimeout(bg, 2*time.Second)
go func() {
defer cancel()
auditLog(bg)
}()
w.WriteHeader(http.StatusAccepted)
}
适合:
- 保留 request id / trace id;
- 不被客户端断开取消;
- 再加自己的 timeout,避免永久后台任务。
不适合:
- 长任务;
- 必须强一致的任务;
- 需要可靠投递的任务。
可靠任务应使用 MQ / outbox / job table。
六、代码陷阱题
下面给 20 道高频陷阱题。
题 1:nil parent
package main
import (
"context"
"fmt"
)
func main() {
ctx, cancel := context.WithCancel(nil)
defer cancel()
fmt.Println(ctx.Err())
}
判断:输出、panic 还是编译错误?
答案:
panic: cannot create context from nil parent
分析:
WithCancel(nil)会进入withCancel;- 源码检查
parent == nil; - nil parent 直接 panic。
依据:
src/context/context.gowithCancel(parent Context)
追问:
- 为什么不用
nil表示没有取消? - 答:为了 API 一致性和避免每层都判 nil,应使用
Background()或TODO()。
题 2:Background().Done() 会阻塞吗?
package main
import (
"context"
"fmt"
"time"
)
func main() {
ctx := context.Background()
select {
case <-ctx.Done():
fmt.Println("done")
case <-time.After(10 * time.Millisecond):
fmt.Println("timeout")
}
}
答案:
timeout
分析:
Background()不可取消;Done()返回 nil;- nil channel 在 select 中对应 case 永远不会 ready;
- 最终
time.Afterready。
追问:
- 如果裸写
<-ctx.Done()呢? - 答:永久阻塞。
题 3:cancel() 会等待 goroutine 退出吗?
package main
import (
"context"
"fmt"
"time"
)
func main() {
ctx, cancel := context.WithCancel(context.Background())
go func() {
<-ctx.Done()
time.Sleep(50 * time.Millisecond)
fmt.Println("worker exit")
}()
cancel()
fmt.Println("main exit")
time.Sleep(100 * time.Millisecond)
}
答案:
可能输出:
main exit
worker exit
分析:
cancel()只关闭 Done,设置 err;- 不等待业务 goroutine;
- worker 收到取消后自己决定何时退出。
追问:
- 如何等待 worker 真退出?
- 答:用
sync.WaitGroup、errgroup.Group或 done channel。
题 4:子取消会影响父吗?
package main
import (
"context"
"fmt"
)
func main() {
parent, cancelParent := context.WithCancel(context.Background())
child, cancelChild := context.WithCancel(parent)
cancelChild()
fmt.Println(parent.Err())
fmt.Println(child.Err())
cancelParent()
}
答案:
<nil>
context canceled
分析:
- 子取消只取消子树;
- 不会反向取消父;
- 这是 Context 没有
Cancel()方法的设计原因之一:下游不应取消上游。
追问:
- 如果想“任一子任务失败取消整体”怎么办?
- 答:用
errgroup.WithContext或上层统一 cancel。
题 5:多次 cancel
package main
import (
"context"
"fmt"
)
func main() {
ctx, cancel := context.WithCancel(context.Background())
cancel()
cancel()
cancel()
fmt.Println(ctx.Err())
}
答案:
context canceled
分析:
CancelFunc可被多个 goroutine 调用;- 第一次生效;
- 后续调用无操作。
追问:
- 多次 cancel 是否 data race?
- 答:标准库保证
CancelFunc可并发调用。
题 6:Cause 第一次取消获胜
package main
import (
"context"
"errors"
"fmt"
)
func main() {
ctx, cancel := context.WithCancelCause(context.Background())
cancel(errors.New("first"))
cancel(errors.New("second"))
fmt.Println(ctx.Err())
fmt.Println(context.Cause(ctx))
}
答案:
context canceled
first
分析:
Err()是标准取消错误;Cause()是业务原因;- 第一次 cancel 设置 cause;
- 第二次不覆盖。
追问:
cancel(nil)的 cause 是什么?- 答:
context.Canceled。
题 7:父 deadline 更早
package main
import (
"context"
"fmt"
"time"
)
func main() {
parent, cancelParent := context.WithTimeout(context.Background(), 50*time.Millisecond)
defer cancelParent()
child, cancelChild := context.WithTimeout(parent, time.Second)
defer cancelChild()
d, _ := child.Deadline()
fmt.Println(time.Until(d) <= time.Second)
<-child.Done()
fmt.Println(child.Err())
}
答案:
true
context deadline exceeded
分析:
- child 设置 1s;
- parent 只有 50ms;
- 子 Context 不能晚于父;
- 最终 50ms 左右被父 deadline 取消。
追问:
- 源码怎么处理更晚 deadline?
- 答:
WithDeadlineCause发现 parent deadline 更早时,返回WithCancel(parent),避免创建无意义 timer。
题 8:循环里 defer cancel
func bad(ctx context.Context, ids []int64) error {
for _, id := range ids {
c, cancel := context.WithTimeout(ctx, time.Second)
defer cancel()
if err := query(c, id); err != nil {
return err
}
}
return nil
}
判断:有什么问题?
答案:
defer cancel()会延迟到bad返回才执行;- 如果循环很多次,中间创建的 timer 和 child Context 会一直存活;
- 可能造成资源泄漏或内存增长。
正确写法:
for _, id := range ids {
c, cancel := context.WithTimeout(ctx, time.Second)
err := query(c, id)
cancel()
if err != nil {
return err
}
}
或者提取函数:
for _, id := range ids {
if err := func() error {
c, cancel := context.WithTimeout(ctx, time.Second)
defer cancel()
return query(c, id)
}(); err != nil {
return err
}
}
追问:
go vet lostcancel能查出所有吗?- 答:不能。vet 是启发式工具,不保证覆盖所有逻辑路径。
题 9:超时后发送结果导致 goroutine 泄漏
func query(ctx context.Context) (string, error) {
ch := make(chan string)
go func() {
time.Sleep(100 * time.Millisecond)
ch <- "ok"
}()
select {
case <-ctx.Done():
return "", ctx.Err()
case v := <-ch:
return v, nil
}
}
如果 ctx 50ms 超时,会怎样?
答案:
query返回context deadline exceeded;- goroutine 100ms 后发送到无缓冲 ch;
- 没有接收者;
- goroutine 泄漏。
修复 1:buffered channel
ch := make(chan string, 1)
修复 2:发送也 select ctx
select {
case ch <- "ok":
case <-ctx.Done():
return
}
追问:
- buffered channel 是否总能解决?
- 答:只能解决单次结果发送;多次发送、流式结果仍需取消协议。
题 10:忘记在发送处监听 Done
func stage(ctx context.Context, in <-chan int) <-chan int {
out := make(chan int)
go func() {
defer close(out)
for v := range in {
out <- v * 2
}
}()
return out
}
问题?
答案:
- 如果下游提前退出,不再接收
out; - 当前 goroutine 会卡在
out <- v * 2; - ctx 即使取消也没用,因为发送处没 select
ctx.Done()。
正确写法:
select {
case out <- v * 2:
case <-ctx.Done():
return
}
题 11:string key 冲突
package main
import (
"context"
"fmt"
)
func libA(ctx context.Context) context.Context {
return context.WithValue(ctx, "user", "alice")
}
func libB(ctx context.Context) context.Context {
return context.WithValue(ctx, "user", 123)
}
func main() {
ctx := context.Background()
ctx = libA(ctx)
ctx = libB(ctx)
fmt.Println(ctx.Value("user"))
}
答案:
123
分析:
WithValue是链式覆盖;- 后加入的
"user"更近; - string key 容易跨包冲突。
正确写法:
type userKey struct{}
ctx = context.WithValue(ctx, userKey{}, user)
追问:
- 为什么 key 类型要不可导出?
- 答:防止外部包构造同类型 key 造成冲突。
题 12:非 comparable key
package main
import (
"context"
)
func main() {
key := []string{"user"}
_ = context.WithValue(context.Background(), key, "alice")
}
答案:
panic: key is not comparable
分析:
- key 必须可比较;
- slice 不可比较;
- 源码会用
reflectlite.TypeOf(key).Comparable()检查。
题 13:Value 放可变 map
type key struct{}
func main() {
m := map[string]string{}
ctx := context.WithValue(context.Background(), key{}, m)
go func() {
ctx.Value(key{}).(map[string]string)["a"] = "1"
}()
go func() {
ctx.Value(key{}).(map[string]string)["b"] = "2"
}()
}
判断:Context 本身并发安全,是否意味着这段安全?
答案:
不安全,存在 data race,甚至可能 panic:
fatal error: concurrent map writes
分析:
- Context 对自身方法并发调用安全;
- Value 里的对象不自动并发安全;
- map 并发写仍然不安全。
追问:
- 如果 Value 放 pointer 呢?
- 答:同理,指向对象的并发安全由对象自己保证。
题 14:WithoutCancel 的 Done 是 nil
package main
import (
"context"
"fmt"
)
func main() {
parent, cancel := context.WithCancel(context.Background())
ctx := context.WithoutCancel(parent)
cancel()
fmt.Println(parent.Err())
fmt.Println(ctx.Err())
fmt.Println(ctx.Done() == nil)
}
答案:
context canceled
<nil>
true
分析:
WithoutCancel不继承 parent 取消;Err()永远不因 parent 取消变非 nil;Done()返回 nil。
追问:
- 后台任务直接用
WithoutCancel是否安全? - 答:不安全。通常还要再套一个
WithTimeout。
题 15:AfterFunc 的 stop 竞态
package main
import (
"context"
"fmt"
"time"
)
func main() {
ctx, cancel := context.WithCancel(context.Background())
stop := context.AfterFunc(ctx, func() {
time.Sleep(10 * time.Millisecond)
fmt.Println("cleanup")
})
cancel()
fmt.Println(stop())
time.Sleep(50 * time.Millisecond)
}
答案:
stop() 可能输出:
false
cleanup
分析:
- cancel 后
AfterFunc的 f 可能已经启动; - stop 返回 false 表示没能阻止 f 启动,或者已被停止;
- stop 不等待 f 完成。
追问:
- 如何等待 cleanup 完成?
- 答:在 f 内关闭 done channel,外部等待该 channel。
题 16:ctx.Err() 检查与操作之间的竞态
func send(ctx context.Context, ch chan<- int) error {
if ctx.Err() != nil {
return ctx.Err()
}
ch <- 1
return nil
}
问题?
答案:
ctx.Err()为 nil 后,ctx 可能立刻被取消;ch <- 1可能永久阻塞;- 应该用 select 同时监听发送和取消。
正确:
select {
case ch <- 1:
return nil
case <-ctx.Done():
return ctx.Err()
}
追问:
Err()适合什么场景?- 答:快速检查、返回原因、避免开始新工作;不能替代 select 协议。
题 17:用 Background 替换 Request Context
func handler(w http.ResponseWriter, r *http.Request) {
ctx := context.Background()
_ = callDownstream(ctx)
}
问题?
答案:
- 客户端断开不会取消下游;
- HTTP/2 cancel 不会传播;
- handler 返回也不会通知下游;
- 可能造成 RPC / DB 残留。
正确:
ctx := r.Context()
追问:
- 什么时候可以用
Background()? - 答:顶层生命周期,例如
main、初始化、测试、服务级后台循环。
题 18:Context 存长期 Struct
type Client struct {
ctx context.Context
}
func NewClient(ctx context.Context) *Client {
return &Client{ctx: ctx}
}
func (c *Client) Do() error {
return call(c.ctx)
}
问题?
答案:
Client通常是长生命周期;- ctx 通常是单次请求生命周期;
- 容易复用已经取消的 ctx;
- 调用者无法为每次
Do设置独立 timeout。
正确:
func (c *Client) Do(ctx context.Context) error {
return call(ctx)
}
题 19:CPU 循环不响应 Context
func hash(ctx context.Context, data []byte) uint64 {
var x uint64
for i := 0; i < 1_000_000_000; i++ {
x += uint64(i)
}
return x
}
问题?
答案:
- ctx 即使取消,循环也不会退出;
- Context 不会强制打断 CPU 计算;
- 需要主动检查。
修复:
for i := 0; i < n; i++ {
if i%4096 == 0 {
select {
case <-ctx.Done():
return 0, ctx.Err()
default:
}
}
x += uint64(i)
}
追问:
- 检查频率怎么定?
- 答:按延迟目标和性能开销 benchmark 决策。
题 20:WithTimeoutCause 手动 cancel 不设置 cause
package main
import (
"context"
"errors"
"fmt"
"time"
)
func main() {
ctx, cancel := context.WithTimeoutCause(
context.Background(),
time.Second,
errors.New("slow backend"),
)
cancel()
fmt.Println(ctx.Err())
fmt.Println(context.Cause(ctx))
}
答案:
context canceled
context canceled
分析:
WithTimeoutCause的 cause 只用于 timeout 触发时;- 返回的是
CancelFunc,不是CancelCauseFunc; - 手动 cancel 不会设置传入的 timeout cause;
- 手动 cancel 的 cause 是
context.Canceled。
追问:
- 想手动设置业务 cause 怎么办?
- 答:用
WithCancelCause。
七、面试高频问题
1. Context 是什么?
- 30 秒回答:Context 用来在调用链中传递取消信号、deadline 和 request-scoped values。
- 中高级回答:它解决的是请求生命周期管理问题,比如客户端断开、请求超时、上游取消后,下游 DB/RPC/worker 应该停止。
- 源码级回答:标准库通过
cancelCtx、timerCtx、valueCtx包装 parent,形成树状结构;父取消时遍历 children 传播取消。 - 深挖:Context 不强杀 goroutine,业务代码必须主动 select
ctx.Done()。 - 错误回答:Context 是全局变量容器。
- 边界:Context 是标准库契约,不是语言关键字。
2. 为什么 Context 通常作为第一个参数?
- 30 秒回答:统一风格,表示函数支持取消和超时。
- 中高级回答:方便调用链传播,也方便
go vet等工具检查。 - 源码级回答:标准库没有强制,但官方文档约定
func Do(ctx context.Context, arg Arg)。 - 深挖:不要把 ctx 放到最后,也不要隐藏在 struct 字段里。
- 错误回答:只是代码风格。
- 边界:构造函数如果真的做 I/O,也可以接收 ctx,但不要把它长期保存。
3. 为什么不要把 Context 存进 struct?
- 30 秒回答:会混淆生命周期。
- 中高级回答:struct 可能长生命周期,ctx 通常是单次请求生命周期;存进去会导致复用已取消 ctx。
- 源码级回答:Context 派生树依赖 parent-child 生命周期,长期持有会导致 child、timer、value 延长存活。
- 深挖:少数兼容旧 API 或对象生命周期等同任务生命周期的场景可以例外。
- 错误回答:绝对不能存。
- 边界:原则是不存,例外必须写清楚生命周期。
4. cancel() 为什么必须调用?
- 30 秒回答:释放资源。
- 中高级回答:它会从 parent children 中移除 child,停止 timer,取消子树。
- 源码级回答:
cancel(removeFromParent=true, ...)最后调用removeChild,timerCtx 还会timer.Stop()。 - 深挖:不调用会泄漏 child 和 children 直到 parent 取消。
- 错误回答:只有超时时才需要 cancel。
- 边界:即使操作成功提前返回,也要 cancel。
5. cancel() 会杀死 goroutine 吗?
- 30 秒回答:不会。
- 中高级回答:只关闭 Done channel,goroutine 必须自己监听并退出。
- 源码级回答:
cancelCtx.cancel只是设置 err/cause、close done、取消 children。 - 深挖:不可取消的 syscall、阻塞 I/O、CPU 循环不会自动停止。
- 错误回答:cancel 会终止所有 goroutine。
- 边界:某些库会把 ctx 绑定到底层 fd deadline 或请求取消,但那是库支持。
6. 父子 Context 的取消关系?
- 30 秒回答:父取消会取消子,子取消不影响父。
- 中高级回答:这是树状生命周期模型,防止下游影响上游。
- 源码级回答:父
childrenmap 持有子 canceler;父 cancel 遍历 children。 - 深挖:想让任一子失败取消整体,需要上层 cancel 或 errgroup。
- 错误回答:子 cancel 会冒泡到父。
- 边界:业务可以手动调用父 cancel,但那不是 Context 自动行为。
7. deadline 如何继承?
- 30 秒回答:子 deadline 不会晚于父。
- 中高级回答:父更早时,子使用父 deadline;子设置更晚 deadline 没意义。
- 源码级回答:
WithDeadlineCause检查parent.Deadline(),如果 parent 更早,返回WithCancel(parent)。 - 深挖:这避免创建多余 timer。
- 错误回答:子 timeout 会覆盖父 timeout。
- 边界:子可以更早取消,但不能延长父的生命周期。
8. WithCancelCause 和 WithCancel 区别?
- 30 秒回答:前者可以记录具体取消原因。
- 中高级回答:
Err()仍是标准错误,Cause()返回业务错误。 - 源码级回答:
cancelCtx有cause error字段,第一次取消写入。 - 深挖:cause 不一定跨 RPC 自动传播,要协议显式传。
- 错误回答:
Err()会返回业务 cause。 - 边界:Go 1.20+。
9. WithTimeoutCause 手动 cancel 会设置 cause 吗?
- 30 秒回答:不会。
- 中高级回答:传入 cause 只在 deadline/timeout 到期时生效。
- 源码级回答:返回的是
CancelFunc,调用时执行c.cancel(true, Canceled, nil)。 - 深挖:手动业务原因应用
WithCancelCause。 - 错误回答:cancel 后 Cause 是传入的 timeout cause。
- 边界:注意区分 timeout cause 和主动 cancel cause。
10. AfterFunc 有什么坑?
- 30 秒回答:stop 不等待函数完成。
- 中高级回答:stop 只尝试阻止 f 启动;返回 false 可能已经启动。
- 源码级回答:
afterFuncCtx用sync.Once保证 f 只启动或被停止一次。 - 深挖:需要等待 f 完成,自己用 done channel。
- 错误回答:stop=false 表示 f 已执行完成。
- 边界:Go 1.21+。
11. WithoutCancel 适合什么?
- 30 秒回答:切断父取消,但保留 value。
- 中高级回答:适合请求结束后仍要短暂做审计、日志、cleanup。
- 源码级回答:
withoutCancelCtx.Done()返回 nil,Err()返回 nil,Value继续向 parent 查。 - 深挖:通常要再包一层
WithTimeout。 - 错误回答:它只是复制一个 Context。
- 边界:Done nil 会导致裸等待永久阻塞。
12. Context Value 为什么不适合传业务参数?
- 30 秒回答:会隐藏依赖。
- 中高级回答:函数签名看不出依赖,类型不安全,查找是链式 O(depth)。
- 源码级回答:
valueCtx每次包装一层,Value逐层比较 key。 - 深挖:适合 request id、trace id、auth info。
- 错误回答:Context Value 就是 map。
- 边界:少量跨边界 metadata 可以。
13. Context key 为什么不要用 string?
- 30 秒回答:避免跨包冲突。
- 中高级回答:不同包都用
"user"时会覆盖。 - 源码级回答:
valueCtx.Value用==比较 key,string 值相同就匹配。 - 深挖:定义不可导出 key 类型并提供 accessor。
- 错误回答:string 不能作为 key。
- 边界:string 可用但不推荐。
14. Done() 可以为 nil 吗?
- 30 秒回答:可以。
- 中高级回答:不可取消 Context 的 Done 为 nil,比如 Background、TODO、WithoutCancel。
- 源码级回答:
emptyCtx.Done()和withoutCancelCtx.Done()返回 nil。 - 深挖:nil channel 在 select 中永远不 ready。
- 错误回答:Done 一定返回可关闭 channel。
- 边界:不要裸
<-ctx.Done()等不可取消 Context。
15. Context 如何导致 goroutine 泄漏?
- 30 秒回答:业务 goroutine 不监听 Done。
- 中高级回答:常见于 pipeline 发送阻塞、超时后无缓冲 channel 发送、worker 卡 I/O。
- 源码级回答:Context 只 close done,不处理业务 goroutine。
- 深挖:用 pprof goroutine profile 看阻塞栈。
- 错误回答:Context 自己会泄漏 goroutine。
- 边界:自定义 parent 可能触发 propagateCancel fallback goroutine。
16. 如何设计超时预算?
- 30 秒回答:顶层设总 deadline,下游按剩余时间分配。
- 中高级回答:避免每层固定 timeout 叠加导致风暴。
- 源码级回答:
Deadline()可读取剩余预算,WithDeadline不会延长父 deadline。 - 深挖:结合 SLO、重试次数、队列等待、下游 P99。
- 错误回答:每层都
WithTimeout(1s)。 - 边界:要考虑网络、DB、排队、重试放大。
17. Context 和 errgroup 什么关系?
- 30 秒回答:errgroup 用 Context 管理一组 goroutine。
- 中高级回答:第一个 goroutine 返回错误会取消派生 ctx,其他 goroutine 应监听退出。
- 源码级回答:
errgroup.WithContext返回 Group 和派生 Context。 - 深挖:errgroup 等待 goroutine 退出,Context 只发取消信号。
- 错误回答:errgroup 会强杀 goroutine。
- 边界:goroutine 函数必须自己尊重 ctx。
18. HTTP Request Context 何时取消?
- 30 秒回答:客户端断开、HTTP/2 cancel、handler 返回。
- 中高级回答:所以 handler 内下游调用应传
r.Context()。 - 源码级回答:net/http 在 server request 生命周期中绑定 cancel。
- 深挖:handler 返回后继续用
r.Context()做后台任务会立刻被取消。 - 错误回答:请求超时才取消。
- 边界:后台任务应用
WithoutCancel加独立 timeout 或可靠任务队列。
19. database/sql 的 Context 能保证取消 SQL 吗?
- 30 秒回答:不绝对,取决于 driver 支持。
- 中高级回答:Context 至少能控制等待连接、查询发起、驱动层取消;具体数据库取消能力依赖 driver。
- 源码级回答:
database/sql暴露QueryContext、ExecContext,非 Context 版本内部用 Background。 - 深挖:要结合数据库慢查询、连接池、driver 行为排查。
- 错误回答:Context 一定能杀掉数据库里的 SQL。
- 边界:生产要设置 DB statement timeout / server-side timeout。
20. 如何测试 Context 超时?
- 30 秒回答:不要依赖真实 sleep 太久。
- 中高级回答:用短 timeout、fake clock、可控 channel、
synctest。 - 源码级回答:Go 1.25+ 标准库
testing/synctest提供 bubble、fake time、Wait。 - 深挖:测试取消路径要覆盖 worker 是否退出、channel 是否关闭、资源是否释放。
- 错误回答:只测 happy path。
- 边界:
synctest有隔离限制,网络和外部进程不适合放 bubble 内。
八、深挖追问链
追问链 1:从 API 到源码
| 层级 | 问题 | 回答要点 |
|---|---|---|
| 1 | Context 是什么? | 传递 deadline、取消信号、request value |
| 2 | Done 是什么? | 取消时关闭的只读 channel |
| 3 | cancel 做什么? | 设置 err/cause,close done,取消 children |
| 4 | children 在哪里? | cancelCtx.children map[canceler]struct{} |
| 5 | 每个 child 都开 goroutine 吗? | 标准 parent 不开,挂 children map |
| 6 | 什么时候开 goroutine? | 自定义 parent 无法识别且无 AfterFunc 时 fallback |
| 7 | 如何优化? | 使用标准 Context 派生,不随意自定义 Context |
追问链 2:从 timeout 到生产故障
| 层级 | 问题 | 回答要点 |
|---|---|---|
| 1 | WithTimeout 是什么? | WithDeadline(now+timeout) |
| 2 | 为什么 defer cancel? | 释放 timer、移除 child |
| 3 | 忘记 cancel 会怎样? | parent 持有 child,timer 存活 |
| 4 | 循环里 defer cancel 呢? | 延迟释放,可能资源堆积 |
| 5 | 生产怎么看? | heap profile、goroutine profile、runtime metrics |
| 6 | 怎么修? | 每轮显式 cancel 或提取函数 |
| 7 | 怎么防? | go vet lostcancel、code review、封装 helper |
追问链 3:从 Cause 到错误治理
| 层级 | 问题 | 回答要点 |
|---|---|---|
| 1 | Err 和 Cause 区别? | Err 标准错误,Cause 业务原因 |
| 2 | Cause 谁决定? | 第一次取消 |
| 3 | 父先取消子会怎样? | 子继承父 cause |
| 4 | 子先取消父后取消呢? | 子保留自己的 cause |
| 5 | Cause 会跨 RPC 吗? | 不自动,需要协议传递 |
| 6 | 日志如何打? | 同时记录 Err、Cause、request id |
| 7 | 面试怎么说? | Cause 改善错误诊断,不改变取消模型 |
追问链 4:从 Value 到 API 设计
| 层级 | 问题 | 回答要点 |
|---|---|---|
| 1 | Value 用来干什么? | request-scoped metadata |
| 2 | 为什么不用业务参数? | 隐藏依赖,类型不安全 |
| 3 | 底层怎么查? | valueCtx 链式查找 |
| 4 | 复杂度? | O(depth) |
| 5 | key 怎么设计? | 不可导出自定义类型 |
| 6 | value 可变对象风险? | 仍需并发保护 |
| 7 | 替代方案? | 显式参数、配置对象、依赖注入 |
追问链 5:从 HTTP 到后台任务
| 层级 | 问题 | 回答要点 |
|---|---|---|
| 1 | handler 里用哪个 ctx? | r.Context() |
| 2 | handler 返回后 ctx 怎样? | server request ctx 取消 |
| 3 | 后台审计怎么办? | WithoutCancel(r.Context()) |
| 4 | 只 WithoutCancel 够吗? | 不够,要加独立 timeout |
| 5 | 可靠任务呢? | MQ/outbox/job table |
| 6 | trace 怎么保留? | Value 继续可查 |
| 7 | 风险? | Done nil、长期任务泄漏 |
九、生产故障与排查
故障 1:goroutine 泄漏
症状:
- goroutine 数持续上涨;
- pprof 显示大量 goroutine 卡在 channel send / receive;
- 请求超时后后台仍在工作。
常见原因:
ch <- result
发送未监听 ctx。
排查:
go tool pprof http://host/debug/pprof/goroutine
看栈:
goroutine xxx [chan send]:
service.query.func1()
修复:
select {
case ch <- result:
case <-ctx.Done():
return
}
工具能证明:
- pprof 能看到 goroutine 阻塞点;
- 不能直接证明业务逻辑为什么没退出,需要结合日志和调用链。
故障 2:timer / context 泄漏
症状:
- heap 增长;
- timer 对象增多;
- 请求量高时内存抖动。
常见原因:
ctx, _ := context.WithTimeout(parent, time.Second)
忘记 cancel。
排查:
go vet ./...
go test -run TestX -bench . -benchmem
go tool pprof heap
修复:
ctx, cancel := context.WithTimeout(parent, time.Second)
defer cancel()
循环内不要直接 defer。
故障 3:超时风暴
错误设计:
API Gateway: 1s
Service A: 1s
Service B: 1s
DB: 1s
Retry: 3 times
问题:
- 上游 1s 已到,下游还在重试;
- 每层固定 timeout 造成请求堆积;
- 重试放大流量。
正确:
deadline, ok := ctx.Deadline()
remaining := time.Until(deadline)
按剩余预算分配:
总预算 800ms
- auth 50ms
- cache 80ms
- db 300ms
- rpc 250ms
- serialization 50ms
故障 4:DB 连接池耗尽
症状:
DBStats.WaitCount增加;WaitDuration增加;- 请求 timeout;
- 数据库慢查询增多。
原因:
- 使用
Query而不是QueryContext; - ctx 取消未传播;
- rows 未关闭;
- driver 不支持取消;
- 数据库端没有 statement timeout。
排查:
stats := db.Stats()
关注:
OpenConnections
InUse
Idle
WaitCount
WaitDuration
MaxIdleClosed
MaxLifetimeClosed
修复:
rows, err := db.QueryContext(ctx, query)
if err != nil { return err }
defer rows.Close()
并配合数据库侧 timeout。
故障 5:HTTP 后台任务被意外取消
错误:
func handler(w http.ResponseWriter, r *http.Request) {
go audit(r.Context())
w.WriteHeader(200)
}
问题:
- handler 返回后
r.Context()取消; - audit 可能立即失败。
修复:
ctx := context.WithoutCancel(r.Context())
ctx, cancel := context.WithTimeout(ctx, 2*time.Second)
go func() {
defer cancel()
audit(ctx)
}()
如果 audit 必须可靠,使用 outbox / MQ。
故障 6:Context Value 大对象导致内存延长存活
错误:
ctx = context.WithValue(ctx, key{}, hugePayload)
问题:
- ctx 沿调用链传递;
- hugePayload 被 valueCtx 持有;
- 请求结束前无法 GC;
- 如果 ctx 被后台任务持有,生命周期更长。
修复:
- 只放 ID;
- 大对象显式参数传递;
- 不把 ctx 存入长生命周期对象。
故障 7:Value 中非线程安全对象造成 data race
错误:
ctx = context.WithValue(ctx, key{}, map[string]string{})
多个 goroutine 同时读写。
排查:
go test -race ./...
注意:
- race detector 能发现真实执行到的 data race;
- 不能证明没有 race;
- 需要覆盖并发路径。
故障 8:CPU 高,取消无效
症状:
- 请求已超时;
- CPU 仍然高;
- pprof 显示大量 CPU 在计算循环。
原因:
- CPU 密集函数没有检查 ctx;
- Context 不会抢占业务逻辑。
修复:
if i%4096 == 0 {
select {
case <-ctx.Done():
return ctx.Err()
default:
}
}
工具总结
| 工具 | 能证明什么 | 不能证明什么 |
|---|---|---|
go vet | lostcancel 等静态问题 | 不能证明所有路径都安全 |
go test -race | 运行到的 data race | 未覆盖路径无法证明 |
go test -bench | Context 包装开销、Value 深度影响 | 不代表生产真实延迟 |
benchstat | 多次 benchmark 差异 | 不解释原因 |
pprof heap | 内存持有、对象分配 | 不直接说明业务语义 |
pprof goroutine | goroutine 阻塞栈 | 不说明谁应该取消 |
pprof cpu | CPU 热点 | 不说明取消设计是否合理 |
go tool trace | goroutine 阻塞、调度、网络等待 | 分析成本较高 |
runtime/metrics | goroutine、heap、timer 等趋势 | 需要业务指标关联 |
expvar | 暴露运行时和业务统计 | 需要提前埋点 |
httptrace | HTTP client 各阶段耗时 | 不覆盖 DB/RPC 内部 |
| 系统日志 | request id、deadline、cause | 依赖日志质量 |
十、面试回答模板
1. 30 秒回答
Context 是 Go 标准库提供的请求生命周期传递机制,用来在调用链中传递取消信号、deadline 和 request-scoped values。它通常作为函数第一个参数传递。
Done()用于监听取消,Err()返回取消原因,Deadline()返回截止时间,Value()只适合放 trace id、request id 等跨 API 边界的元数据。使用WithCancel、WithTimeout、WithDeadline创建子 Context 后,必须调用cancel()释放资源。
2. 2 分钟回答
Context 的核心不是存值,而是生命周期控制。服务端一个请求可能派生 DB、RPC、MQ、worker 多个操作,当客户端断开或上游超时时,下游都应该尽快停止。Go 通过 Context 把这个取消信号显式传递下去。父 Context 取消会取消所有子 Context,但子取消不会影响父。timeout 本质上是 deadline,到期后关闭 Done channel,
Err()返回DeadlineExceeded。生产里需要注意三点:第一,
cancel()必须调用,否则 timer 和 parent.children 会延长存活;第二,Context 不会强杀 goroutine,业务代码必须 selectctx.Done();第三,Value 只放 request-scoped metadata,不要放业务参数或大对象。
3. 5 分钟深入回答
源码上,Context 主要由
emptyCtx、valueCtx、cancelCtx、timerCtx组成。Background和TODO基于emptyCtx,不可取消,Done()返回 nil。每次WithValue会创建一层valueCtx,查找时沿 parent 链逐层比较 key,所以复杂度是 O(depth)。
WithCancel会创建cancelCtx,里面有 parent、mutex、懒创建的 done channel、children map、err 和 cause。创建子 Context 时,propagateCancel会尝试把 child 挂到 parent 的 children map 上。这样父取消时遍历 children 即可传播,不需要每个子都开 goroutine。只有当 parent 是自定义 Context,既不能识别底层 cancelCtx,也没有 AfterFunc 能力时,才会 fallback 起一个 goroutine 监听 parent.Done。
WithTimeout是WithDeadline(now+timeout),底层是timerCtx,包含一个time.Timer。如果父 deadline 更早,就不会创建更晚的 timer。取消时会 stop timer,并从 parent children 中移除自己。Go 1.20 后有 Cause 系列 API,可以保留业务取消原因;Go 1.21 后有AfterFunc和WithoutCancel,分别用于取消回调和切断父取消。工程上,我会用顶层 deadline 控制总预算,下游按剩余时间分配,避免每层固定 timeout 造成风暴。HTTP handler 传
r.Context(),DB 用QueryContext,并发任务用errgroup.WithContext,pipeline 每一级的接收和发送都要监听ctx.Done()。
4. 源码级回答
cancelCtx的关键是children map[canceler]struct{}和懒创建donechannel。Done()第一次调用才创建 channel;如果取消时还没创建,会存一个全局 closed channel,避免不必要分配。Err()和done使用atomic.Value做快路径读取,取消路径用 mutex 保证只取消一次。cancelCtx.cancel会设置 err/cause、关闭 done、递归取消 children,然后按需removeChild。timerCtx嵌入cancelCtx,增加 deadline 和 timer;取消时先走 cancelCtx,再 stop timer。AfterFunc用afterFuncCtx和sync.Once处理 stop 与取消启动回调之间的竞态。
5. 生产事故分析回答
如果线上出现请求超时后 goroutine 仍上涨,我会先看 goroutine pprof,确认是否大量卡在 channel send、DB query、HTTP round trip 或 worker select。然后检查调用链是否传了
r.Context(),是否用了QueryContext/NewRequestWithContext,pipeline 发送和接收两侧是否都监听ctx.Done()。如果 heap 增长,我会重点查循环内WithTimeout是否defer cancel延迟释放,或者是否忘记 cancel。对于 timeout 风暴,我会看每层 timeout、retry、queue wait 和下游 P99,改成顶层总 deadline + 剩余预算分配。
十一、本章速记
- Context 是生命周期信号,不是参数袋。
ctx通常作为函数第一个参数,命名为ctx。- 不要传 nil Context;不确定时用
context.TODO()。 Background()是根 Context,永不取消、无 deadline、无 value。- 父取消会取消子;子取消不会取消父。
- 子 Context 的 deadline 不会晚于父。
cancel()不会等待 goroutine 退出。cancel()不会强制终止 goroutine。- 创建可取消 Context 后应调用
cancel()。 - 循环里不要随便
defer cancel(),可能延迟释放大量 timer。 Done()可能返回 nil。Err()是状态检查,不是并发协议替代品。- 发送和接收都可能需要 select
ctx.Done()。 WithTimeout本质是WithDeadline(now+timeout)。WithCancelCause的Err()仍是标准错误,Cause()才是业务原因。- 第一次取消决定 err/cause,后续 cancel 不覆盖。
WithTimeoutCause手动 cancel 不会设置 timeout cause。AfterFunc的 stop 不等待回调完成。WithoutCancel的Done()是 nil,通常要再包 timeout。- Context Value 是链式查找,不是 map。
- Context key 不要用 string,应用不可导出自定义类型。
- Value 中对象的并发安全不由 Context 保证。
- HTTP handler 里应传
r.Context(),不要换成Background()。 - DB 查询应优先用
QueryContext、ExecContext。 - 超时预算应从顶层 deadline 向下分配,避免层层固定 timeout。
十二、自测题
A. 简答题
- Context 解决的核心问题是什么?
- 为什么 Context 通常作为函数第一个参数?
Background()和TODO()的区别是什么?- 为什么不要传 nil Context?
cancel()做了哪些事情?没有做哪些事情?- 父子 Context 的取消关系是什么?
WithTimeout和WithDeadline的关系是什么?Err()和Cause()有什么区别?- 为什么 Context Value 不适合传业务参数?
WithoutCancel的危险点是什么?
B. 代码题
代码题 1
ctx := context.Background()
<-ctx.Done()
fmt.Println("exit")
问:会输出吗?为什么?
代码题 2
parent, cancelParent := context.WithCancel(context.Background())
child, cancelChild := context.WithCancel(parent)
cancelChild()
fmt.Println(parent.Err())
fmt.Println(child.Err())
cancelParent()
问:输出是什么?
代码题 3
ctx, cancel := context.WithCancelCause(context.Background())
cancel(errors.New("a"))
cancel(errors.New("b"))
fmt.Println(ctx.Err())
fmt.Println(context.Cause(ctx))
问:输出是什么?
代码题 4
func f(ctx context.Context) error {
ctx, cancel := context.WithTimeout(ctx, time.Second)
return work(ctx)
}
问:问题是什么?如何修?
代码题 5
ctx := context.WithValue(context.Background(), "user", "alice")
ctx = context.WithValue(ctx, "user", 123)
fmt.Println(ctx.Value("user"))
问:输出是什么?为什么不推荐?
C. 系统设计 / 故障分析题
- 设计一个 HTTP API:总超时 800ms,需要调用 Redis、DB、RPC。你如何设计 Context 和 timeout 预算?
- 线上 goroutine 数持续上涨,pprof 发现大量 goroutine 卡在 channel send,且日志显示请求早已 timeout。你如何定位和修复?
- 某服务 DB 连接池耗尽,请求大量
context deadline exceeded。你如何判断是 DB 慢、连接池配置问题、还是 Context 没传递?
参考答案
简答题答案
- Context 解决跨调用链传递取消信号、deadline 和 request-scoped values 的问题。
- 作为第一个参数可以统一 API 风格,显式表示函数支持取消和超时,也方便静态检查。
- 两者运行行为类似,都是非 nil、不可取消、无 deadline、无 value;区别是语义:
Background用于根,TODO用于暂时不知道传什么。 - nil 会导致派生函数 panic,也会让调用链每层都要判空;应使用
Background或TODO。 cancel()设置 err/cause、关闭 Done、取消 children、移除 parent 引用、停止 timer;不会等待 goroutine,不会强杀 goroutine。- 父取消会取消所有子孙;子取消不影响父。
WithTimeout(parent, d)等价于WithDeadline(parent, time.Now().Add(d))。Err()返回标准取消错误;Cause()返回更具体的取消原因。- 因为隐藏依赖、类型不安全、链式查找、容易滥用;业务参数应显式传递。
WithoutCancel不继承取消和 deadline,Done()为 nil;如果不再设置独立 timeout,后台任务可能永久运行或阻塞。
代码题答案
代码题 1
不会输出。
原因:
Background().Done()返回 nil;- 对 nil channel 接收永久阻塞。
代码题 2
输出:
<nil>
context canceled
原因:
cancelChild()只取消 child;- parent 不受影响。
代码题 3
输出:
context canceled
a
原因:
Err()返回标准错误;Cause()返回第一次 cancel 的 cause;- 第二次 cancel 不覆盖。
代码题 4
问题:
- 忘记调用
cancel(); - timer 和 child Context 可能泄漏直到超时或父取消。
修复:
func f(ctx context.Context) error {
ctx, cancel := context.WithTimeout(ctx, time.Second)
defer cancel()
return work(ctx)
}
代码题 5
输出:
123
原因:
WithValue是链式包装;- 后加入的
"user"key 更近,覆盖前面的查找结果; - string key 容易跨包冲突,应使用不可导出自定义 key 类型。
系统设计 / 故障分析题答案
题 1:HTTP API timeout 预算
设计:
入口:r.Context()
→ WithTimeout(r.Context(), 800ms)
→ Redis: 80ms
→ DB: 300ms
→ RPC: 250ms
→ response encode: 50ms
→ buffer: 120ms
原则:
- 顶层设总预算;
- 下游读取剩余 deadline;
- 不要每层固定 800ms;
- retry 必须消耗总预算;
- 所有 I/O 用 Context-aware API。
题 2:goroutine 卡 channel send
定位:
pprof goroutine看阻塞栈;- 找到 channel send 位置;
- 检查下游是否提前返回;
- 检查发送处是否监听
ctx.Done(); - 检查 result channel 是否无缓冲且超时后无人接收。
修复:
select {
case out <- v:
case <-ctx.Done():
return
}
如果是单次结果,也可以用容量为 1 的 buffered channel,但流式 pipeline 仍要 select ctx。
题 3:DB 连接池耗尽
排查:
-
看
db.Stats():OpenConnectionsInUseWaitCountWaitDuration
-
看是否使用
QueryContext/ExecContext; -
看
rows.Close()是否遗漏; -
看慢查询日志;
-
看 driver 是否支持取消;
-
看数据库是否设置 statement timeout;
-
看上游是否层层 timeout + retry 放大。
修复:
- 所有 DB 操作传 ctx;
defer rows.Close();- 设置合理
SetMaxOpenConns; - 配置 DB 端 statement timeout;
- 减少重试风暴;
- 按总 deadline 分配查询预算。