4. context 进阶特性
4. context 进阶特性
这一篇把视线拉到底层和边缘:context 接口的内部结构、生产环境最常见的优雅关机场景、Go 1.21 引入的两个新 API,以及怎么避免 goroutine 泄漏。
Context 接口的四个方法
context.Context 是一个极简接口:
type Context interface {
Deadline() (deadline time.Time, ok bool)
Done() <-chan struct{}
Err() error
Value(key any) any
}
四个方法的语义:
| 方法 | 返回 | 说明 |
|---|---|---|
Deadline() | 时间 + bool | ok=false 表示没设置截止时间 |
Done() | 只读 channel | 取消时关闭,未取消时读阻塞 |
Err() | error | nil = 未取消;Canceled / DeadlineExceeded = 已取消 |
Value(key) | any | nil = 不存在 |
Background() 的四项返回值都是”空”:
bg := context.Background()
deadline, ok := bg.Deadline() // zero, false
done := bg.Done() // nil(永远不关闭)
err := bg.Err() // nil
val := bg.Value("any") // nil
实际开发里几乎不会自己实现这个接口。标准库提供了 emptyCtx、cancelCtx、timerCtx、valueCtx 四个实现,覆盖所有场景。理解它们的存在主要为了看懂源码和文档。
内部类型一览
emptyCtx - Background 和 TODO 的类型,无 deadline / cancel / value
cancelCtx - WithCancel 返回的类型,有 cancel + children 列表
timerCtx - WithTimeout/WithDeadline 返回,内嵌 cancelCtx + timer
valueCtx - WithValue 返回,存 key-value + 指向 parent
层级关系:timerCtx 内嵌 cancelCtx,cancelCtx 和 valueCtx 都内嵌 Context(父 context)。
取消传播的实现:每个 cancelCtx 维护一个 children 列表,记录自己派生的子 cancelCtx。cancel() 时遍历 children 列表,依次取消所有子。这就是”父取消 → 子也被取消”的底层机制。
parent, _ := context.WithCancel(context.Background())
child1, _ := context.WithCancel(parent)
child2, _ := context.WithCancel(parent)
// parent 内部的 children 列表里有 child1 和 child2
// parent cancel 时,会遍历 children,把 child1、child2 都取消
signal.NotifyContext:优雅关机
signal.NotifyContext(Go 1.16+)把系统信号变成 context 的取消信号。生产环境的标准用法:
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
defer stop()
go runServer(ctx)
<-ctx.Done() // 阻塞,直到收到 Ctrl+C 或 kill
收到 SIGINT/SIGTERM 时,ctx.Done() 关闭,所有派生的 goroutine 自动收到取消信号。
完整的优雅关机模式
HTTP 服务器优雅关机的标准结构:
func main() {
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
defer stop()
srv := &http.Server{Addr: ":8080"}
go func() {
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
log.Fatal(err)
}
}()
<-ctx.Done() // 等待信号
log.Println("收到信号,开始关机...")
// 给关机本身一个超时,防止卡住
shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
if err := srv.Shutdown(shutdownCtx); err != nil {
log.Printf("关机超时: %v", err)
}
log.Println("服务器已退出")
}
关键点:
signal.NotifyContext监听信号,自动取消 context- 关机本身也用
WithTimeout限制时间,防止某些请求无限拖延 srv.Shutdown(ctx)是优雅关机:停止接受新连接,等待已有连接处理完毕
AfterFunc:取消后的回调(Go 1.21+)
context.AfterFunc(ctx, fn) 注册一个回调,ctx 被取消后,fn 在新 goroutine 中执行一次。
ctx, cancel := context.WithCancel(context.Background())
done := make(chan struct{})
context.AfterFunc(ctx, func() {
fmt.Println("context 已取消,执行清理")
close(done)
})
cancel() // 触发回调
<-done // 等待回调完成
取消注册
AfterFunc 返回一个 stop 函数,调用它可以取消注册,阻止回调执行:
stop := context.AfterFunc(ctx, func() {
fmt.Println("不应该看到这行")
})
stop() // 取消注册
cancel()
// 回调不会被调用
相比传统写法
传统写法需要额外一个 goroutine 监听 Done():
// 旧写法:需要额外 goroutine
go func() {
<-ctx.Done()
// 清理逻辑
}()
// 新写法:AfterFunc 更轻量
context.AfterFunc(ctx, func() {
// 清理逻辑
})
AfterFunc 的四个优势:
- 不需要显式启动 goroutine
- 返回
stop可以取消注册 - 回调保证只执行一次
- 如果 ctx 已经取消,回调立即在新 goroutine 执行
WithoutCancel:脱离父的取消(Go 1.21+)
context.WithoutCancel(parent) 返回一个不会被父取消影响的子 context,但继承父的所有 Value。
parent, cancel := context.WithCancel(context.Background())
child := context.WithoutCancel(parent)
cancel() // 取消父
fmt.Println(parent.Err()) // canceled
fmt.Println(child.Err()) // nil —— 不受影响
应用场景:请求后的后台任务
请求已经返回了,但需要做一些”发后即忘”的操作(比如埋点、异步发邮件、清理)。这些任务不应该因为请求 context 结束而被取消。
func handler(ctx context.Context) {
// 业务逻辑...
// 启动后台任务,使用脱离请求的 context
bgCtx := context.WithoutCancel(ctx)
go func(ctx context.Context) {
traceID := ctx.Value("trace_id") // 仍然能拿到请求的值
log.Printf("异步任务开始, trace=%s", traceID)
time.Sleep(5 * time.Second)
log.Println("异步任务完成")
}(bgCtx)
// handler 返回后,原 ctx 会被取消,但后台任务继续
}
// 如果后台任务也需要超时,再包一层
bgCtxWithTimeout, cancel := context.WithTimeout(bgCtx, 30*time.Second)
defer cancel()
WithoutCancel 解决的是生命周期不匹配问题:请求 context 是秒级,后台任务是分钟级,强行用同一 context 会导致后台任务被提前掐断。
goroutine 泄漏:三种典型模式
goroutine 泄漏是 Go 并发里最隐蔽的 bug:goroutine 永远阻塞,无法退出,内存随时间持续上涨。几乎所有的泄漏都是因为缺了 select + ctx.Done()。
模式一:不监听 ctx.Done()
// ❌ 泄漏:channel 没人发数据,goroutine 永久阻塞
go func() {
val := <-ch // 永远等不到
}()
// ✅ 修复:select 加上取消分支
go func() {
select {
case val := <-ch:
fmt.Println(val)
case <-ctx.Done():
return
}
}()
模式二:忘记 cancel
// ❌ 泄漏:每次创建都分配内部资源,不调用 cancel 不释放
for _, item := range items {
ctx, _ := context.WithCancel(parent) // 丢弃了 cancel!
process(ctx, item)
}
// ✅ 修复:循环里显式 cancel,或抽成函数走 defer
for _, item := range items {
func() {
ctx, cancel := context.WithCancel(parent)
defer cancel()
process(ctx, item)
}()
}
模式三:channel 发送无 select
// ❌ 泄漏:无人接收时发送永久阻塞
go func() {
ch <- "data" // 无人接收,永久阻塞
}()
// ✅ 修复:select 加取消分支
go func() {
select {
case ch <- "data":
case <-ctx.Done():
return
}
}()
正确的完整模式
写一个不会泄漏的并发 worker:
func doWork(ctx context.Context, id int) <-chan string {
out := make(chan string, 1) // ✅ buffer 或保证有接收方
go func() {
defer close(out) // ✅ 始终关闭输出 channel
select {
case <-time.After(100 * time.Millisecond):
out <- fmt.Sprintf("worker-%d 完成", id)
case <-ctx.Done(): // ✅ 监听取消
fmt.Printf("worker-%d: 被取消\n", id)
return
}
}()
return out
}
三条铁律:
- 每个 goroutine 都要有
select + ctx.Done()的退出路径 - 每个 channel 写入都要 select,避免无接收方时永久阻塞
defer cancel()+defer close(out)是避免泄漏的两道保险
小结
这一篇的几个点都偏向生产环境:
signal.NotifyContext是服务优雅关机的标准入口AfterFunc替代”goroutine + Done()“的传统监听写法,更轻量WithoutCancel解决请求 context 与后台任务的生命周期不匹配- goroutine 泄漏几乎都是缺
select + ctx.Done()导致的