Go Context 超时与取消:从源码到陷阱的深度拆解

Go 0 次阅读
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——这意味着 selectcase <-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) // 从父节点中移除自己
    }
}

关键点就两个:

  1. close(c.done) ——这才是"发送取消信号"的本质。所有监听 <-ctx.Done() 的 goroutine 会立刻解除阻塞。
  2. 递归取消所有子节点——用一个 for range c.children 实现了树形传播。

3. timerCtx——带超时/截止时间的 cancelCtx

type timerCtx struct {
    cancelCtx                         // 继承 cancelCtx 的所有能力
    timer    *time.Timer              // 超时定时器
    deadline time.Time
}

WithTimeoutWithDeadline 的语法糖:

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()
    }
}

这个示例展示了三层超时嵌套:

  1. 最外层:HTTP 请求自带 context(客户端断开自动取消)
  2. 中间层:服务端 500ms 超时保护
  3. 内层:每个子任务监听 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 等。业务数据走函数参数。


总结

  1. Context 本质是信号广播器——通过 close(channel) 实现一对多的取消通知,不直接终止 goroutine,而是由 goroutine 自己监听并响应。
  2. defer cancel() 是铁律——无论函数是否提前返回,都要确保取消函数被调用,否则定时器无法释放,造成 goroutine 泄漏。
  3. 取消信号沿树形结构自动传播——propagateCancel 通过 children map 实现"父取消,子必取消",形成从根到叶的级联取消链。

延伸阅读

  • Go 官方 context 包文档Context 的 Go Blog 文章
  • 如果你想了解 context 是如何与 net/http 底层结合的,推荐阅读 net/http/request.goRequest.Context() 的实现
  • Go 1.20 新增的 context.WithCancelCausecontext.Cause 的源码实现,在 context/context.go 中只有不到 50 行