Go Context 超时控制:让 Goroutine 该停就停
你的 Goroutine 是否还在傻傻等待?客户端已经走了,服务端还在拼命计算——用 Context 超时控制,让该停的协程立刻停下。
从一个场景说起
想象你正在开发一个电商搜索服务。用户在前端输入关键词,后端需要同时调用三个下游服务:商品搜索、库存查询、推荐算法,然后把结果聚合返回。
如果没有超时控制,代码可能是这样的:
func SearchHandler(w http.ResponseWriter, r *http.Request) {
keyword := r.URL.Query().Get("q")
// 三个 goroutine 并发查询,但没有任何超时控制
results := make(chan string, 3)
go func() { results <- searchProducts(keyword) }()
go func() { results <- queryStock(keyword) }()
go func() { results <- getRecommendations(keyword) }()
// 等三个结果都返回
for i := 0; i < 3; i++ {
<-results
}
// 返回聚合结果...
}
问题在哪?
用户等 2 秒就关掉页面了,但你服务的三个 goroutine 还在吭哧吭哧地跑。如果库存服务刚好挂了(响应慢、重试多),你的协程数会疯狂膨胀,内存飙升,最终整个服务雪崩。
方案一:channel + select 手动控制
func SearchWithTimeout(keyword string, timeout time.Duration) string {
done := make(chan struct{})
result := make(chan string)
go func() {
result <- searchProducts(keyword)
close(done)
}()
select {
case <-time.After(timeout):
return "timeout"
case <-done:
return <-result
}
}
这个方案的问题是:只能管一个 goroutine。当你启动 N 个 goroutine,每个都要绑定一个 done channel,还要统一取消——代码很快就变成一团乱麻。
这正是 context 包要解决的问题。
核心原理
Go 1.7 引入的 context 包,本质上是一套在 goroutine 树之间传递取消信号、超时时间和元数据的标准协议。
从接口说起
type Context interface {
Deadline() (deadline time.Time, ok bool)
Done() <-chan struct{}
Err() error
Value(key interface{}) interface{}
}
四个方法,功能清晰:
| 方法 | 作用 |
|---|---|
Deadline() |
返回这个 Context 被自动取消的截止时间 |
Done() |
返回一个只读 channel,关闭时表示 Context 被取消 |
Err() |
返回取消原因:Canceled 或 DeadlineExceeded |
Value() |
获取绑定的键值对数据 |
关键理解点:Done() 返回的 channel 是只读的,且永远不会被写入数据。它只有一种状态变化——被 close()。读一个关闭的 channel 会立即返回零值,这就是"取消信号"的传播机制。
树形结构:为什么是"树"?
Context 最聪明的设计是树形结构。
// 根节点
ctxRoot := context.Background()
// 第一层:可取消的 context
ctxA, cancelA := context.WithCancel(ctxRoot)
// 第二层:基于 ctxA 再创建
ctxB, cancelB := context.WithTimeout(ctxA, 3*time.Second)
// 第三层:基于 ctxB 再创建
ctxC := context.WithValue(ctxB, "traceID", "abc-123")
形成一棵树:
Background
└── ctxA (可手动取消)
└── ctxB (3秒后自动取消)
└── ctxC (携带 traceID)
取消传播的方向是:从父到子,单向传递。 当 cancelA() 被调用时,信号会沿着树向下传播到 ctxB、ctxC。反过来,取消 ctxB 不会影响 ctxA。
WithCancel 源码拆解
WithCancel 是最基础的取消机制。创建一对父子 context,返回一个 cancel 函数让你手动触发取消。
func WithCancel(parent Context) (ctx Context, cancel CancelFunc) {
c := newCancelCtx(parent) // 创建 cancelCtx 实例
propagateCancel(parent, &c) // 建立父子关系
return &c, func() { c.cancel(true, Canceled) }
}
这一小段代码干了两件大事:
第一:创建 cancelCtx
type cancelCtx struct {
Context // 嵌入父 Context
mu sync.Mutex // 保护并发访问
done atomic.Value // 延迟创建的 channel
children map[canceler]struct{} // 所有子节点
err error // 取消后的错误
}
done 字段是"懒加载"的——只有第一次调用 Done() 方法时才会创建 channel。这意味着如果没人监听取消信号,就白白浪费一次 channel 创建。
第二:建立传播链(这是关键)
func propagateCancel(parent Context, child canceler) {
// 情况1:父节点不可取消(如 Background),啥也不干
if parent.Done() == nil {
return
}
// 情况2:父节点已经被取消了,子节点也被立即取消
select {
case <-parent.Done():
child.cancel(false, parent.Err())
return
default:
}
// 情况3:找到可取消的父 context,把自己挂上去
if p, ok := parentCancelCtx(parent); ok {
p.mu.Lock()
if p.err != nil {
child.cancel(false, p.err)
} else {
if p.children == nil {
p.children = make(map[canceler]struct{})
}
p.children[child] = struct{}{}
}
p.mu.Unlock()
} else {
// 情况4:找不到父节点(自定义类型),启动新 goroutine 监控
go func() {
select {
case <-parent.Done():
child.cancel(false, parent.Err())
case <-child.Done():
}
}()
}
}
情况 4 值得注意: 如果你把 Context 包装成自定义结构体,Go 无法找到内部的 cancelCtx,只能启动一个新 goroutine 来监控取消信号。这是一种性能损失和潜在的 goroutine 泄漏风险。
cancel():信号如何层层传递
func (c *cancelCtx) cancel(removeFromParent bool, err error) {
if err == nil {
panic("context: internal error: missing cancel error")
}
c.mu.Lock()
if c.err != nil {
c.mu.Unlock()
return // 已经取消过了,直接返回(幂等)
}
c.err = err
// 关闭 done channel——这就是"发信号"
close(c.done)
// 递归取消所有子节点
for child := range c.children {
child.cancel(false, err)
}
c.children = nil
c.mu.Unlock()
// 从父节点把自己移除
if removeFromParent {
removeChild(c.Context, c)
}
}
画一下取消传播的流程:
cancelA() 调用
├─ 关闭 ctxA.done channel
├─ 递归取消子节点 ctxB
│ ├─ 关闭 ctxB.done channel
│ └─ 递归取消子节点 ctxC
│ └─ 关闭 ctxC.done channel
└─ 从父节点(Background)移除自己
所有监听了 <-ctx.Done() 的 goroutine 都会同时收到信号。 这就是 channel 关闭的"广播效应"。
WithTimeout:自动取消的秘密
func WithTimeout(parent Context, timeout time.Duration) (Context, CancelFunc) {
return WithDeadline(parent, time.Now().Add(timeout))
}
func WithDeadline(parent Context, deadline time.Time) (Context, CancelFunc) {
// 父节点的 deadline 更早?直接用 WithCancel,让父节点来管
if cur, ok := parent.Deadline(); ok && cur.Before(deadline) {
return WithCancel(parent)
}
c := &timerCtx{
cancelCtx: newCancelCtx(parent),
deadline: deadline,
}
propagateCancel(parent, c)
d := time.Until(deadline)
if d <= 0 {
// 已经超时了,立即取消
c.cancel(true, DeadlineExceeded)
return c, func() { c.cancel(true, Canceled) }
}
c.mu.Lock()
if c.err == nil {
// 关键:用 time.AfterFunc 在 deadline 到达时自动取消
c.timer = time.AfterFunc(d, func() {
c.cancel(true, DeadlineExceeded)
})
}
c.mu.Unlock()
return c, func() { c.cancel(true, Canceled) }
}
核心是 time.AfterFunc(d, func() { c.cancel(true, DeadlineExceeded) })——到达 deadine 后自动触发 cancel,关闭 done channel。
有一个重要的边界情况:如果父 context 的 deadline 比子 context 更早,子 context 的 deadline 其实永远不会触发,因为父节点会先取消。此时 Go 会聪明地退化为 WithCancel,省掉一个 Timer。
深入细节
细节一:defer cancel() 不是可选的
很多人写 WithTimeout 时不调返回的 cancel 函数,以为超时到了会自动清理:
// ❌ 错误做法
ctx, _ := context.WithTimeout(context.Background(), 3*time.Second)
doSomething(ctx)
// ✅ 正确做法
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel() // 确保提前返回时也能释放资源
doSomething(ctx)
为什么必须调? 如果 doSomething 在 1 秒内就返回了,但 Timer 设的是 3 秒。 defer cancel() 会提前停止 Timer,释放 time.AfterFunc 创建的 goroutine。不调的话,这个 Timer 会一直等到 3 秒后才被 GC 回收。
细节二:ctx.Err() 的两种错误
var Canceled = errors.New("context canceled")
var DeadlineExceeded = deadlineExceededError{} // 实现了 error 接口
你用 context.Cause(ctx) 可以区分:
select {
case <-ctx.Done():
switch {
case errors.Is(ctx.Err(), context.Canceled):
fmt.Println("被手动取消了")
case errors.Is(ctx.Err(), context.DeadlineExceeded):
fmt.Println("超时了")
}
}
实战意义: 超时了你可以重试,被手动取消了你应该直接退出。
细节三:WithValue 的正确打开方式
// ❌ 错误示范:传业务参数
ctx := context.WithValue(parentCtx, "userName", "张三")
// ✅ 正确用法:传递请求范围的无状态元数据
type contextKey string
const traceIDKey contextKey = "traceID"
ctx := context.WithValue(parentCtx, traceIDKey, "req-abc-123")
// 取值
traceID, ok := ctx.Value(traceIDKey).(string)
官方建议 Context 中的 Value 只放跨 API 边界和跨进程边界的元数据,比如 traceID、认证令牌,而不是业务参数。用自定义类型作为 key(而不是 string)可以避免不同包之间的 key 冲突。
细节四:空的 context 和 TODO 的区别
var background = new(emptyCtx)
var todo = new(emptyCtx)
Background() 和 TODO() 返回的是同一个底层类型,行为完全一致。区别在语义上:
Background():根 context,所有 context 都应该从它派生TODO():占位符,表示"我不确定这里该传什么 context,以后重构时再改"
细节五:goroutine 泄漏的隐蔽场景
func handle(ctx context.Context) {
result := make(chan int)
go func() {
result <- expensiveCompute() // 如果 ctx 取消了,没人读 result
}()
select {
case <-ctx.Done():
return // goroutine 会卡在 result <- ... 上,泄漏!
case r := <-result:
fmt.Println(r)
}
}
修复方案: 给 result channel 加缓冲,或者用带 select 的写法:
result := make(chan int, 1) // 缓冲 1,保证 goroutine 能写完
最佳实践
实践一:函数签名的"第一条规则"
如果一个函数可能阻塞或需要超时,第一个参数必须是 ctx context.Context:
// Go 社区约定俗成的规范
func QueryUser(ctx context.Context, userID int) (*User, error)
func ProcessOrder(ctx context.Context, order *Order) error
这是 Go 标准库(如 database/sql、net/http)广泛采用的做法。
实践二:完整的一个超时控制模板
package main
import (
"context"
"errors"
"fmt"
"net/http"
"time"
)
// 模拟一个可能很慢的下游服务
func slowAPI(ctx context.Context, name string) (string, error) {
// 模拟随机延迟 1-5 秒
select {
case <-ctx.Done():
return "", ctx.Err()
case <-time.After(time.Duration(1+name[0]%4) * time.Second):
return name + " 返回结果", nil
}
}
// 聚合多个下游调用,受统一超时控制
func aggregate(ctx context.Context, services []string) ([]string, error) {
results := make(chan string, len(services))
errCh := make(chan error, len(services))
for _, svc := range services {
svc := svc // 循环变量副本
go func() {
res, err := slowAPI(ctx, svc)
if err != nil {
errCh <- err
return
}
results <- res
}()
}
var outputs []string
for i := 0; i < len(services); i++ {
select {
case <-ctx.Done():
// 超时或取消,返回已收集的结果
return outputs, ctx.Err()
case res := <-results:
outputs = append(outputs, res)
case err := <-errCh:
return outputs, err
}
}
return outputs, nil
}
func main() {
// 设置 2 秒超时
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
services := []string{"商品服务", "库存服务", "推荐服务", "价格服务"}
results, err := aggregate(ctx, services)
if errors.Is(err, context.DeadlineExceeded) {
fmt.Println("⏰ 超时了,但已收集到部分结果:", results)
} else if err != nil {
fmt.Println("❌ 出错了:", err)
} else {
fmt.Println("✅ 全部成功:", results)
}
}
运行输出(取决于时间):
⏰ 超时了,但已收集到部分结果: [商品服务 返回结果 库存服务 返回结果]
关键点:
- 所有 goroutine 共享同一个
ctx - 用带缓冲的 channel 避免 goroutine 泄漏
select监听ctx.Done()实现优雅退出errors.Is区分超时和其他错误
实践三:HTTP 服务的 Context 传递
func handler(w http.ResponseWriter, r *http.Request) {
// 从请求中获取 context
ctx := r.Context()
// 设置 3 秒超时
ctx, cancel := context.WithTimeout(ctx, 3*time.Second)
defer cancel()
result, err := queryDatabase(ctx, "SELECT ...")
if errors.Is(err, context.DeadlineExceeded) {
http.Error(w, "请求超时", http.StatusGatewayTimeout)
return
}
if err != nil {
http.Error(w, "内部错误", http.StatusInternalServerError)
return
}
fmt.Fprintf(w, "结果: %s", result)
}
func queryDatabase(ctx context.Context, query string) (string, error) {
// 模拟数据库查询
select {
case <-ctx.Done():
return "", ctx.Err()
case <-time.After(5 * time.Second):
return "data", nil
}
}
当客户端断开连接时,r.Context() 会自动取消。再加上你的超时控制,双重保障。
实践四:区分"超时"和"取消"
ctx, cancel := context.WithTimeout(parent, 100*time.Millisecond)
defer cancel()
// ... 某个业务逻辑 ...
if errors.Is(ctx.Err(), context.DeadlineExceeded) {
// 超时了——可以选择重试
retry()
} else if errors.Is(ctx.Err(), context.Canceled) {
// 被人为取消了,不需要重试
return
}
总结
-
Context 的本质是 goroutine 树的取消信号广播器,通过 channel 关闭 + 递归遍历子节点实现信号的向下传播。它不是直接杀死 goroutine,而是给 goroutine 一个"你应该退出了"的通知。
-
用 WithTimeout/WithDeadline 做超时控制,搭配 WithCancel 做手动取消。父子 context 之间的 deadline 会自动取更早的那个,不需要手动做嵌套超时。
-
最佳实践三件套:函数签名第一个参数传 ctx → 用
defer cancel()释放资源 → 在 select 中监听<-ctx.Done()实现优雅退出。不要用 Context 传业务参数。
延伸阅读
- Go 标准库 context 源码——只有 500 行,值得一读
- 搜索「Go context cancel 树形传播 源码分析」深入了解 propagateCancel 的多种分支情况