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())defercancel()// 必须调用,释放资源3.4 context.WithTimeout / WithDeadline
context.WithTimeout(parent, d)在指定时长后自动取消;context.WithDeadline(parent, t)在指定的绝对时间点自动取消。两者本质相同,WithTimeout内部就是基于WithDeadline实现的。
// 3 秒后自动取消ctx,cancel:=context.WithTimeout(parent,3*time.Second)defercancel()// 指定绝对截止时间ctx,cancel:=context.WithDeadline(parent,time.Now().Add(3*time.Second))defercancel()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():// 被取消了,清理并退出returnctx.Err()default:// 继续工作}4.2 ctx.Err()
ctx.Err()返回取消原因,只有两种可能:
context.Canceled:手动调用cancel()导致的取消context.DeadlineExceeded:超时或截止时间到达
iferr:=ctx.Err();err!=nil{switcherr{casecontext.Canceled:log.Println("手动取消")casecontext.DeadlineExceeded:log.Println("超时")}}5. Context 使用规范
以下是 Go 社区公认的 Context 使用规范,也是代码评审的硬性标准。
5.1 作为函数第一个参数
Context 应作为函数的第一个参数,命名统一为ctx:
funcdoWork(ctx context.Context,idint)error{// ...}5.2 不要存储在结构体中
Context 是请求域的,不应作为结构体的字段长期保存。它应该随函数调用流动,而不是被某个对象持有。
// ❌ 错误:Context 存入结构体typeServicestruct{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)// ✅ 正确:显式传参funcdoWork(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)defercancel()6.2 在 HTTP handler 中未传递 request 的 Context
HTTP 请求自带 Context,请求断开时它会自动取消。很多新手会忽略这一点,自己新建一个Background(),导致请求断开后下游仍在工作:
funchandler(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(),取消无效funcdoWork(ctx context.Context){for{// 死循环,永不退出}}// ✅ 正确:主动检查 ctx.Done()funcdoWork(ctx context.Context){for{select{case<-ctx.Done():returndefault:// 正常工作}}}7. 代码示例:超时控制与级联取消
7.1 超时控制
下面是一个带超时控制的 HTTP 请求示例。WithTimeout确保请求在 3 秒内完成,否则自动取消:
// 超时控制funcfetchWithTimeout(ctx context.Context,urlstring)([]byte,error){ctx,cancel:=context.WithTimeout(ctx,3*time.Second)defercancel()// 必须调用,释放资源req,_:=http.NewRequestWithContext(ctx,"GET",url,nil)resp,err:=http.DefaultClient.Do(req)iferr!=nil{returnnil,err}deferresp.Body.Close()returnio.ReadAll(resp.Body)}7.2 级联取消
在 Web 服务中,handler 应使用r.Context(),这样当客户端断开连接时,整个调用链都会被取消:
// 级联取消funchandler(w http.ResponseWriter,r*http.Request){ctx:=r.Context()// 请求断开时自动取消result,err:=doWork(ctx)iferr!=nil{http.Error(w,err.Error(),http.StatusInternalServerError)return}w.Write(result)}funcdoWork(ctx context.Context)([]byte,error){// 模拟耗时操作,监听 ctx.Done()select{case<-ctx.Done():returnnil,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,可以让你的服务在超时和客户端断开时优雅降级,而不是无限等待或泄漏资源。这是从「能跑」到「生产可用」的关键一步。