1. 引言
在 Go 的并发编程中,goroutine 的启动非常廉价,但如何优雅地停止它们却是一门学问。直接 kill 一个 goroutine 是不可能的,Go 官方给出的答案是:通过 Context 传递取消信号,让 goroutine 自己决定何时退出。
本篇是「Go 并发实战」系列的第 14 篇,我们将深入剖析 context 包的设计目的、四种创建方式、父子级联传播机制,以及在实际 Web 服务中如何正确实现超时与取消。这是 Go 面试中 ★★★★★ 级的高频考点,也是生产环境排障的必备技能。
2. Context 的设计目的
Context 的核心设计目的是:跨 API 边界传递取消信号、超时、截止时间以及请求域数据。
在传统的函数调用中,参数传递是显式的、同步的。但在并发场景下,一个请求可能被拆分成多个 goroutine 并行处理,每个 goroutine 又可能调用多个下游服务。此时,如果上游请求超时或被用户取消,下游的所有 goroutine 都应该立即感知并停止工作,而不是继续空转浪费资源。
Context 就是为此而生的一套标准化的信号传递机制。它像一根贯穿整个调用链的「信号线」,从请求入口一直延伸到最底层的数据库查询或 HTTP 调用。
3. 四种创建方式
context 包提供了四种核心的创建方式,分别对应不同的使用场景。
3.1 context.Background()
context.Background() 返回一个空的根 Context,它永远不会被取消,也没有携带任何值。通常用于 main 函数、初始化过程,以及作为整个调用链的最顶层父 Context。
ctx := context.Background()
3.2 context.TODO()
context.TODO() 同样返回一个空的 Context,但它表达的是「暂时不确定用什么 Context,先用占位符」的语义。当你重构代码、暂时不想改动函数签名,或不确定该传什么 Context 时,用它来占位。
ctx := context.TODO()
注意:
TODO()不是「偷懒」的借口,而是显式标记「这里需要后续补上真正的 Context」。在代码评审中,看到TODO()应当追问:这里为什么不用Background()或请求的 Context?
3.3 context.WithCancel(parent)
context.WithCancel(parent) 返回一个子 Context 和一个 cancel 函数。调用 cancel() 后,该 Context 及其所有子孙 Context 都会被取消。
ctx, cancel := context.WithCancel(context.Background())
defer cancel() // 必须调用,释放资源
3.4 context.WithTimeout / WithDeadline
context.WithTimeout(parent, d) 在指定时长后自动取消;context.WithDeadline(parent, t) 在指定的绝对时间点自动取消。两者本质相同,WithTimeout 内部就是基于 WithDeadline 实现的。
// 3 秒后自动取消
ctx, cancel := context.WithTimeout(parent, 3*time.Second)
defer cancel()
// 指定绝对截止时间
ctx, cancel := context.WithDeadline(parent, time.Now().Add(3*time.Second))
defer cancel()
3.5 context.WithValue
context.WithValue(parent, k, v) 用于携带请求域数据,例如 traceID、用户 ID、认证令牌等。这些数据随请求在整个调用链中传递。
ctx := context.WithValue(parent, "traceID", "abc-123")
注意:
WithValue只应用于请求域数据,不要用它来传递可选参数或业务配置。
4. Context 的传播机制
Context 的传播是父子级联的:父 Context 取消,则所有子 Context 全部取消;但子 Context 取消,不会影响父 Context。
当父 Context 被取消时,取消信号会沿着这棵树向下广播,所有子孙 Context 的 Done() channel 都会被关闭。
4.1 ctx.Done()
ctx.Done() 返回一个只读 channel。当 Context 被取消(手动 cancel、超时、截止时间到达)时,这个 channel 会被关闭。goroutine 可以通过 select 监听它:
select {
case <-ctx.Done():
// 被取消了,清理并退出
return ctx.Err()
default:
// 继续工作
}
4.2 ctx.Err()
ctx.Err() 返回取消原因,只有两种可能:
context.Canceled:手动调用cancel()导致的取消context.DeadlineExceeded:超时或截止时间到达
if err := ctx.Err(); err != nil {
switch err {
case context.Canceled:
log.Println("手动取消")
case context.DeadlineExceeded:
log.Println("超时")
}
}
5. Context 使用规范
以下是 Go 社区公认的 Context 使用规范,也是代码评审的硬性标准。
5.1 作为函数第一个参数
Context 应作为函数的第一个参数,命名统一为 ctx:
func doWork(ctx context.Context, id int) error {
// ...
}
5.2 不要存储在结构体中
Context 是请求域的,不应作为结构体的字段长期保存。它应该随函数调用流动,而不是被某个对象持有。
// ❌ 错误:Context 存入结构体
type Service struct {
ctx context.Context
}
// ✅ 正确:Context 作为参数传递
func (s *Service) Handle(ctx context.Context) {
// ...
}
5.3 不要传 nil Context
永远不要传 nil 作为 Context。不确定时用 context.TODO() 占位:
// ❌ 错误
doWork(nil)
// ✅ 正确
doWork(context.TODO())
5.4 WithValue 仅用于请求域数据
WithValue 只用于 traceID、用户 ID 等请求域数据,不用于传可选参数。业务参数应显式传参:
// ❌ 错误:用 WithValue 传业务参数
ctx := context.WithValue(ctx, "timeout", 3*time.Second)
// ✅ 正确:显式传参
func doWork(ctx context.Context, timeout time.Duration) {
// ...
}
5.5 Context 是并发安全的
多个 goroutine 可以同时使用同一个 Context,Done() 的读取和 Err() 的调用都是并发安全的。但要注意:Context 的值是不可变的,不要试图修改它。
6. 易错点与常见误解
6.1 忘记调用 cancel() 导致 Context 泄漏
这是最常见的错误。WithCancel / WithTimeout 返回的 cancel 函数必须被调用,否则 Context 及其关联的定时器资源不会被释放,造成内存泄漏。
// ❌ 错误:忘记 defer cancel()
ctx, _ := context.WithTimeout(parent, 3*time.Second)
// ✅ 正确:defer cancel()
ctx, cancel := context.WithTimeout(parent, 3*time.Second)
defer cancel()
6.2 在 HTTP handler 中未传递 request 的 Context
HTTP 请求自带 Context,请求断开时它会自动取消。很多新手会忽略这一点,自己新建一个 Background(),导致请求断开后下游仍在工作:
func handler(w http.ResponseWriter, r *http.Request) {
// ❌ 错误:丢弃了 r.Context()
ctx := context.Background()
// ✅ 正确:使用 r.Context()
ctx := r.Context()
}
6.3 用 WithValue 传递业务参数
如前所述,WithValue 只用于请求域数据。用 context.WithValue 传业务参数会让代码难以阅读和维护,且存在类型安全问题。
6.4 误以为取消 Context 会杀死 goroutine
这是最大的误解。取消 Context 只是关闭了 Done() channel,并不会强制终止 goroutine。goroutine 必须主动检查 ctx.Done() 并自行退出。如果 goroutine 不监听 Context,取消信号对它毫无作用。
// ❌ 错误:不监听 ctx.Done(),取消无效
func doWork(ctx context.Context) {
for {
// 死循环,永不退出
}
}
// ✅ 正确:主动检查 ctx.Done()
func doWork(ctx context.Context) {
for {
select {
case <-ctx.Done():
return
default:
// 正常工作
}
}
}
7. 代码示例:超时控制与级联取消
7.1 超时控制
下面是一个带超时控制的 HTTP 请求示例。WithTimeout 确保请求在 3 秒内完成,否则自动取消:
// 超时控制
func fetchWithTimeout(ctx context.Context, url string) ([]byte, error) {
ctx, cancel := context.WithTimeout(ctx, 3*time.Second)
defer cancel() // 必须调用,释放资源
req, _ := http.NewRequestWithContext(ctx, "GET", url, nil)
resp, err := http.DefaultClient.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
return io.ReadAll(resp.Body)
}
7.2 级联取消
在 Web 服务中,handler 应使用 r.Context(),这样当客户端断开连接时,整个调用链都会被取消:
// 级联取消
func handler(w http.ResponseWriter, r *http.Request) {
ctx := r.Context() // 请求断开时自动取消
result, err := doWork(ctx)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.Write(result)
}
func doWork(ctx context.Context) ([]byte, error) {
// 模拟耗时操作,监听 ctx.Done()
select {
case <-ctx.Done():
return nil, ctx.Err()
case <-time.After(2 * time.Second):
return []byte("done"), nil
}
}
当客户端断开连接时,r.Context() 的 Done() channel 被关闭,doWork 中的 select 会立即返回 ctx.Err(),整个调用链优雅退出,不会产生泄漏的 goroutine。
8. 总结
Context 是 Go 并发编程中控制协程生命周期的核心工具。掌握它的关键在于理解:
- 传播机制:父子级联,父取消则子全部取消
- 主动检查:取消信号只是「通知」,goroutine 必须主动监听
ctx.Done() - 资源释放:
defer cancel()是铁律,防止 Context 泄漏 - 使用规范:作为第一个参数、不存结构体、不传 nil、WithValue 仅用于请求域数据
在 Web 服务中,正确使用 r.Context() 配合 WithTimeout,可以让你的服务在超时和客户端断开时优雅降级,而不是无限等待或泄漏资源。这是从「能跑」到「生产可用」的关键一步。
转载自 CSDN-专业IT技术社区
原文链接:https://blog.csdn.net/vipxieliang/article/details/167221042



