Go Context 超时与取消:从源码到陷阱的深度拆解
你以为只是调个
cancel()?不调用它,你的 Go 服务正在悄悄泄漏 goroutine。
从一个场景说起
先看一段"看起来没毛病"的代码。你在写一个 HTTP 服务,需要查询数据库,2 秒超时:
func QueryUser(db *sql.DB, userID int) (string, error) {
// 设置 2 秒超时
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
// 执行查询——注意,这里没有 defer cancel()
row := db.QueryRowContext(ctx, "SELECT name FROM users WHERE id = ?", userID)
var name string
err := row.Scan(&name)
if err != nil {
return "", err
}
return name, nil
}
这段代码上线后,运维发现:服务的内存以肉眼可见的速度增长,一周后 OOM 重启了。你排查半天,发现 QueryRowContext 在 2 秒内正常返回了,但 ctx 创建后 cancel 根本没有被调用。
这个"小疏忽"导致的 goroutine 泄漏,就是今天要讲的核心问题——Context 的正确使用,远比你想象的复杂。
核心原理
Context 到底是什么?
Go 1.7 引入的 context 包,本质是一个信号传递机制。它不直接终止 goroutine,而是通过关闭 channel 来广播信号,由 goroutine 自己监听并响应。
先看最核心的接口定义:
// $GOROOT/src/context/context.go
type Context interface {
Deadline() (deadline time.Time, ok bool)
Done() <-chan struct{}
Err() error
Value(key any) any
}
四个方法各司其职:
| 方法 | 作用 | 调用时机 |
|---|---|---|
Deadline() |
返回设置的截止时间,没设置则 ok=false |
想在超时前做判断时 |
Done() |
返回一个只读 channel,被取消/超时时关闭 | select 监听取消信号 |
Err() |
返回取消原因(Canceled / DeadlineExceeded) | Done channel 关闭后 |
Value() |
获取 key-value 数据 | 传递请求级元数据 |
重点理解 Done()——它返回的是一个只读且从不接收数据的 channel。永远不会有人往这个 channel 里写数据,唯一让它解除阻塞的方式就是 close()。所以 <-ctx.Done() 能读到零值,是因为关闭后的 channel 会立即返回零值。
四种实现,一张图看懂
整个 context 包只有 4 种具体实现,构成了一颗树状结构:
context.Background() (emptyCtx)
│
┌───────────┼───────────┐
│ │ │
WithCancel WithDeadline WithValue
(cancelCtx) (timerCtx) (valueCtx)
│
WithCancel
(cancelCtx)
1. emptyCtx——所有 ctx 的根
// 源码位置:context.go
type emptyCtx int
func (*emptyCtx) Deadline() (deadline time.Time, ok 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() 和 context.TODO() 返回的都是 emptyCtx,只是语义不同:
- Background:根上下文,你用它作为起点
- TODO:表示"这里暂时不知道传什么 context,先占个位"
Done() 返回 nil——这意味着 select 中 case <-nil 永远不会被选中(goroutine 不会阻塞也不会执行),所以 emptyCtx 是不可取消的。
2. cancelCtx——取消信号的核心
// 源码位置:context.go
type cancelCtx struct {
Context // 嵌入父 Context
mu sync.Mutex
done atomic.Value // 内部是 chan struct{}
children map[canceler]struct{} // 所有子节点
err error
}
WithCancel 创建的就是它:
func WithCancel(parent Context) (ctx Context, cancel CancelFunc) {
c := newCancelCtx(parent)
propagateCancel(parent, &c) // ⭐ 关键:建立父子关联
return &c, func() { c.cancel(true, Canceled) }
}
func newCancelCtx(parent Context) cancelCtx {
return cancelCtx{Context: parent}
}
取消的核心逻辑在 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——这就是"发送取消信号"的本质
if c.done == nil {
c.done = closedchan
} else {
close(c.done)
}
// 递归取消所有子节点——链式传播
for child := range c.children {
child.cancel(false, err)
}
c.children = nil
c.mu.Unlock()
if removeFromParent {
removeChild(c.Context, c) // 从父节点中移除自己
}
}
关键点就两个:
close(c.done)——这才是"发送取消信号"的本质。所有监听<-ctx.Done()的 goroutine 会立刻解除阻塞。- 递归取消所有子节点——用一个
for range c.children实现了树形传播。
3. timerCtx——带超时/截止时间的 cancelCtx
type timerCtx struct {
cancelCtx // 继承 cancelCtx 的所有能力
timer *time.Timer // 超时定时器
deadline time.Time
}
WithTimeout 是 WithDeadline 的语法糖:
func WithTimeout(parent Context, timeout time.Duration) (Context, CancelFunc) {
return WithDeadline(parent, time.Now().Add(timeout))
}
WithDeadline 的实现逻辑:
func WithDeadline(parent Context, d time.Time) (Context, CancelFunc) {
// 如果父 ctx 的 deadline 更早,父 ctx 取消时子自然会取消
// 直接用 WithCancel 即可,无需额外定时器
if cur, ok := parent.Deadline(); ok && cur.Before(d) {
return WithCancel(parent)
}
c := &timerCtx{
cancelCtx: newCancelCtx(parent),
deadline: d,
}
propagateCancel(parent, c) // 建立父子关联
dur := time.Until(d)
if dur <= 0 {
// 已经超时了,直接取消
c.cancel(true, DeadlineExceeded)
return c, func() { c.cancel(false, Canceled) }
}
c.mu.Lock()
defer c.mu.Unlock()
if c.err == nil {
// 关键:用 time.AfterFunc 在 deadline 到达时自动取消
c.timer = time.AfterFunc(dur, func() {
c.cancel(true, DeadlineExceeded)
})
}
return c, func() { c.cancel(true, Canceled) }
}
核心洞察:timerCtx 的 cancel 方法继承自 cancelCtx,但在超时后会自动触发 cancel()。所以它既能手动取消,也能自动超时取消。
4. valueCtx——传递请求级数据
type valueCtx struct {
Context
key, val any
}
func (c *valueCtx) Value(key any) any {
if c.key == key {
return c.val
}
// 🌲 沿着父链向上查找
return c.Context.Value(key)
}
注意:查找是单向向上的,子节点可以获取父节点的值,反过来不行。并且每次查找都是 O(n) 的递归遍历,所以不要用 Context 传大量数据。
propagateCancel——"一取消,全取消"的秘密武器
这是整个 context 包最精妙的设计。它负责把子 ctx 注册到父 ctx 的 children 表中,实现"父取消,子必取消":
func propagateCancel(parent Context, child canceler) {
// 情况 1:父 ctx 不可取消(如 emptyCtx),啥也不做
done := parent.Done()
if done == nil {
return
}
// 情况 2:父 ctx 已经被取消了,立即取消子 ctx
select {
case <-done:
child.cancel(false, parent.Err())
return
default:
}
// 情况 3:找到最近的 cancelCtx 父节点,把子节点注册进去
if p, ok := parentCancelCtx(parent); ok {
p.mu.Lock()
if p.err != nil {
// double check:父节点已被取消
child.cancel(false, p.err)
} else {
if p.children == nil {
p.children = make(map[canceler]struct{})
}
// ⭐ 把子 ctx 注册到父的 children map 中
p.children[child] = struct{}{}
}
p.mu.Unlock()
} else {
// 情况 4:自定义 Context 实现了 Done(),兜底方案
// 启动一个 goroutine 监听双向取消
go func() {
select {
case <-parent.Done():
child.cancel(false, parent.Err())
case <-child.Done():
}
}()
}
}
四种情况对应四种场景。重点是情况 3——这是最常见的路径,通过 children map 实现了 O(1) 的树形传播。
深入细节
陷阱 1:不调 cancel 导致的 goroutine 泄漏
回到开头的例子。context.WithTimeout 返回的 cancel 函数如果没调,会发生什么?
func leakExample() {
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
_ = ctx
// ❌ 忘记 defer cancel()
// 函数返回时,timerCtx 内部的 timer 还在运行
// timer 持有对 ctx 的引用,ctx 又持有 parent 的引用……
// 整个链条都无法被 GC 回收
}
WithTimeout 内部用 time.AfterFunc 创建了一个定时器,这个定时器会持有 timerCtx 的引用。如果函数在超时前返回,定时器还没触发,cancel 也没调用,那么:
- 定时器无法被 GC 回收
- 定时器引用的
timerCtx无法被 GC 回收 timerCtx中的children map引用的所有子 ctx 也无法被 GC 回收
✅ 正确的做法:
func QueryUser(db *sql.DB, userID int) (string, error) {
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel() // ⭐ 无论函数是否提前返回,都确保 cancel 被调用
row := db.QueryRowContext(ctx, "SELECT name FROM users WHERE id = ?", userID)
// ...
}
陷阱 2:select 的"空转"问题
很多人写 context 监听会写成这样:
// ❌ 反面教材
for {
if ctx.Err() != nil { // 主动轮询——效率低
return ctx.Err()
}
// 做别的事...
time.Sleep(100 * time.Millisecond)
}
✅ 正确的做法是用 select:
// ✅ 正确做法
for {
select {
case <-ctx.Done():
return ctx.Err() // channel 被关闭,立即感知
default:
// 做别的事...
}
}
select 用的是 Go runtime 的事件通知机制,而不是轮询。当 ctx.Done() 的 channel 被关闭时,G 会从 gwait 状态被唤醒,零延迟感知取消信号。
陷阱 3:WithValue 的 key 冲突
// ❌ string 做 key,容易冲突
ctx := context.WithValue(ctx, "traceId", "abc123")
// 另一处代码也用 "traceId",可能覆盖
ctx = context.WithValue(ctx, "traceId", "def456") // 不是覆盖,是新建一层
WithValue 不是修改,而是在链上新增一个节点。上面的代码中,查询 ctx.Value("traceId") 返回的是 "def456"——因为从链顶往下查,先找到新的。这本身没问题,但如果有第三方库也用 "traceId" 这个 key,就会互相污染。
✅ 最佳实践:用自定义类型做 key:
// 定义私有类型防止冲突
type contextKey string
const TraceIDKey contextKey = "traceId"
ctx := context.WithValue(ctx, TraceIDKey, "abc123")
// 即使别人也用 "traceId" 字符串,类型不同查不到
与 Java/Rust 的对比
如果你写过 Java 或者 Rust,Context 的设计会让你似曾相识:
| 语言 | 超时控制方案 | 取消传播机制 | 是否内置 |
|---|---|---|---|
| Go | context.WithTimeout |
channel close 广播 | ✅ 标准库 |
| Java | CompletableFuture.orTimeout() |
Future.cancel(true) — 通过线程中断 |
✅ JDK 8+ |
| Rust | tokio::time::timeout() |
JoinHandle::abort() — 本质是 drop |
❌ 依赖 tokio |
Go 的独特之处在于:取消信号是通过 channel 的 close 语义广播的,而非中断或异常。一个 channel 被 close 后,所有监听它的 goroutine 都能立即读到零值,天然实现了一对多的广播。这在设计上比 Java 的线程中断模型更安全(不会在任意代码路径上抛 InterruptedException)。
最佳实践
实践 1:函数签名规范
第一个参数就传 context,且命名为 ctx:
// ✅ 规范写法
func DoSomething(ctx context.Context, arg1 string, arg2 int) error
实践 2:永远 defer cancel
func handleRequest(ctx context.Context) error {
// 如果你需要基于父 ctx 增加超时
ctx, cancel := context.WithTimeout(ctx, 100*time.Millisecond)
defer cancel() // ⭐ 铁律:无论什么路径返回,都调 cancel
// ...
}
如果你的函数不需要增加额外的超时或取消,直接透传 ctx,不要创建新的。
实践 3:用 WithCancelCause 传递取消原因(Go 1.20+)
func process(ctx context.Context) error {
ctx, cancel := context.WithCancelCause(ctx)
result := make(chan int, 1)
go func() {
// 模拟数据库查询失败
time.Sleep(100 * time.Millisecond)
cancel(errors.New("db connection refused"))
}()
select {
case <-ctx.Done():
// 获取具体的取消原因,而不是笼统的 Canceled
return context.Cause(ctx)
case r := <-result:
return r
}
}
实践 4:透传 vs 包装——分清楚场景
| 场景 | 做法 |
|---|---|
| 调用下游服务/数据库 | 透传,不要加自己的超时 |
| 调用外部不可控的第三方 API | 包装,加自己的超时保护 |
| 批量处理每个子任务 | 每个子任务用独立的 WithTimeout |
| 后台定时任务 | 用 context.Background(),别用 request 的 ctx |
实践 5:一个完整的 HTTP 服务超时示例
package main
import (
"context"
"fmt"
"log"
"net/http"
"time"
)
func main() {
http.HandleFunc("/search", searchHandler)
log.Fatal(http.ListenAndServe(":8080", nil))
}
func searchHandler(w http.ResponseWriter, r *http.Request) {
// 从请求中获取 context(自带 client disconnect 感知)
ctx := r.Context()
// 增加服务端超时保护——防止上游 proxy 超时设置过长
ctx, cancel := context.WithTimeout(ctx, 500*time.Millisecond)
defer cancel()
result := make(chan string, 1)
// 启动后台 goroutine 执行耗时搜索
go func() {
result <- doSearch(ctx, r.URL.Query().Get("q"))
}()
select {
case <-ctx.Done():
// 客户端断开 或 超时
w.WriteHeader(http.StatusGatewayTimeout)
fmt.Fprintf(w, "request cancelled: %v\n", ctx.Err())
case res := <-result:
w.WriteHeader(http.StatusOK)
fmt.Fprintf(w, "result: %s\n", res)
}
}
func doSearch(ctx context.Context, query string) string {
// 模拟多个子任务
ch := make(chan string, 3)
// 任务 1:查询缓存
go func() {
result, err := queryCache(ctx, query)
if err != nil {
return
}
ch <- result
}()
// 任务 2:查询数据库
go func() {
result, err := queryDB(ctx, query)
if err != nil {
return
}
ch <- result
}()
// 谁先返回就用谁
select {
case <-ctx.Done():
return "timeout"
case r := <-ch:
return r
}
}
func queryCache(ctx context.Context, query string) (string, error) {
// 模拟:查 Redis 耗时 200ms
select {
case <-time.After(200 * time.Millisecond):
return "cache_result", nil
case <-ctx.Done():
return "", ctx.Err()
}
}
func queryDB(ctx context.Context, query string) (string, error) {
// 模拟:查 MySQL 耗时 800ms(超时后会被取消)
select {
case <-time.After(800 * time.Millisecond):
return "db_result", nil
case <-ctx.Done():
return "", ctx.Err()
}
}
这个示例展示了三层超时嵌套:
- 最外层:HTTP 请求自带 context(客户端断开自动取消)
- 中间层:服务端 500ms 超时保护
- 内层:每个子任务监听
ctx.Done()
实践 6:不要用 Context 传业务参数
// ❌ 反面教材
ctx := context.WithValue(ctx, "userID", 123)
ctx = context.WithValue(ctx, "userName", "小明")
// 业务参数应该用函数参数传递,而不是藏在 context 里
// ✅ 正确做法
func GetUser(ctx context.Context, userID int, userName string) (*User, error)
Context 只传请求级元数据,如:TraceID、认证 Token、请求来源 IP 等。业务数据走函数参数。
总结
- Context 本质是信号广播器——通过
close(channel)实现一对多的取消通知,不直接终止 goroutine,而是由 goroutine 自己监听并响应。 defer cancel()是铁律——无论函数是否提前返回,都要确保取消函数被调用,否则定时器无法释放,造成 goroutine 泄漏。- 取消信号沿树形结构自动传播——
propagateCancel通过children map实现"父取消,子必取消",形成从根到叶的级联取消链。
延伸阅读
- Go 官方 context 包文档 和 Context 的 Go Blog 文章
- 如果你想了解 context 是如何与
net/http底层结合的,推荐阅读net/http/request.go中Request.Context()的实现 - Go 1.20 新增的
context.WithCancelCause和context.Cause的源码实现,在context/context.go中只有不到 50 行