Go Context 超时控制:从源码到实战一次讲透

Go 0 次阅读
Go Context 超时控制:从源码到实战一次讲透

请求超时了,goroutine 还在跑?你的服务可能正在泄露资源。

在 Go 的日常开发中,你一定遇到过这样的场景:给某个 HTTP 接口设置了 3 秒超时,结果下游数据库查询跑了 8 秒才返回,这 8 秒里该请求对应的一堆 goroutine 全部被"挂住"不释放,流量一大,服务直接 OOM。根本原因就是——你没有正确使用 Context 做超时控制。

Go 的 context 包从 Go 1.7 引入,是 goroutine 之间传递取消信号、超时信号、元数据的标准接口。但大多数人只停留在"在函数签名里加个 ctx 参数"的层面,对它的树形派生机制、canceler 传播路径、timerCtx 的定时器实现并不清楚。

今天,我们从一个你肯定踩过的坑说起,系统讲透 Go Context 的超时控制机制。


从一个场景说起

假设你写了一个订单查询服务,需要同时查数据库和调用外部库存 API:

// 问题代码 —— 没有超时控制的写法
func GetOrderInfo(orderID string) (*Order, error) {
    // 查数据库
    dbCh := make(chan *DBResult, 1)
    go func() {
        dbCh <- queryDB(orderID)
    }()

    // 调库存服务
    invCh := make(chan *InvResult, 1)
    go func() {
        invCh <- queryInventory(orderID)
    }()

    // 等两者都返回
    dbResult := <-dbCh
    invResult := <-invCh

    return assembleOrder(dbResult, invResult)
}

这段代码有什么问题?如果 queryDBqueryInventory 阻塞了(比如数据库连接池满、下游服务挂掉),这个 goroutine 永远不返回。但请求方(比如 HTTP 客户端)可能早在 2 秒前就超时断开了连接。启动的这些 goroutine 会永久阻塞,成为僵尸 goroutine。

更可怕的是——你无法从外部杀死一个 goroutine。这是 Go 并发模型的设计哲学:协程必须自行决定何时退出

Context 就是来解决这个问题的:它给每个 goroutine 一根"引线",外部可以随时剪断它。


核心原理

Context 的三驾马车

context 包的核心是 Context 接口,定义了 4 个方法:

type Context interface {
    Deadline() (deadline time.Time, ok bool)  // 返回截止时间
    Done() <-chan struct{}                     // 返回关闭信号(只读 channel)
    Err() error                                // 返回取消原因
    Value(key any) any                         // 获取绑定值
}

围绕这个接口,标准库提供了 三个核心工厂函数,用于从父 Context 派生子 Context:

函数 作用 触发取消条件
WithCancel 可手动取消 调用 cancel()
WithTimeout 超时自动取消 时间到 / 调用 cancel()
WithDeadline 指定截止时间 到点触发 / 调用 cancel()
WithValue 绑定键值对 不参与取消

这里有一个关键认知:Context 是不可变的(immutable)。你无法给一个已有 Context 添加超时或值,只能通过 WithXxx 从它派生出一个新的 Context 节点。每次派生都会创建一个新节点,形成一棵 Context 树

从源码看 Context 树的构建

让我们深入 context 包的源码,看看这棵树是怎么长出来的。

Go 源码中有 四种 具体的 Context 类型:

// 1. emptyCtx —— 空 context,不可取消,没有 deadline,没有值
type emptyCtx int

// 2. cancelCtx —— 可取消,核心实现
type cancelCtx struct {
    Context                          // 嵌入父 Context
    mu       sync.Mutex
    done     chan struct{}           // 取消时关闭此 channel
    children map[canceler]struct{}   // 所有子 canceler
    err      error                   // 取消原因
}

// 3. timerCtx —— 带超时的取消
type timerCtx struct {
    cancelCtx                        // 嵌入 cancelCtx
    timer    *time.Timer             // 底层计时器
    deadline time.Time               // 截止时间
}

// 4. valueCtx —— 只绑值,不参与取消
type valueCtx struct {
    Context
    key, val any
}

emptyCtx 就是 context.Background()context.TODO() 返回的类型,它不会超时、不可取消,是所有 Context 树的根节点。

关键机制:propagateCancel

当调用 WithCancel(parent)WithTimeout(parent, d) 时,内部会执行 propagateCancel,将新节点挂到父节点的 children 树上:

func propagateCancel(parent Context, child canceler) {
    // 如果父节点不可取消(比如 emptyCtx),直接返回
    if parent.Done() == nil {
        return
    }

    // 向上查找第一个可取消的父节点(cancelCtx 或 timerCtx)
    if p, ok := parentCancelCtx(parent); ok {
        p.mu.Lock()
        if p.err != nil {
            // 父节点已经被取消了,子节点立即取消
            child.cancel(false, p.err)
        } else {
            // 将子节点挂到父节点的 children map 中
            if p.children == nil {
                p.children = make(map[canceler]struct{})
            }
            p.children[child] = struct{}{}
        }
        p.mu.Unlock()
    } else {
        // 没法挂靠(用户自定义 Context),只能用 goroutine 监听
        go func() {
            select {
            case <-parent.Done():
                child.cancel(false, parent.Err())
            case <-child.Done():
            }
        }()
    }
}

核心要点:只有 cancelCtxtimerCtx 实现了 canceler 接口,valueCtx 不参与 children 树。所以画出来是这样的:

            context.Background()  (emptyCtx, 根)
                     │
              WithCancel  ──→  cancelCtx(A)
              ┌──────────────┼──────────────┐
              │              │              │
         cancelCtx(B)   valueCtx(无children)  timerCtx(C)
              │                                │
         cancelCtx(D)                     cancelCtx(E)

当 A 被取消时,B、C、D、E 全部被级联取消。这就是 cancel 信号的树形传播

取消的原子操作

核心在于 cancelCtx.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                           // 记录取消原因
    if c.done == nil {
        c.done = closedchan              // 懒初始化
    } else {
        close(c.done)                    // ★ 关闭 channel,广播信号
    }

    // ★ 递归取消所有子节点
    for child := range c.children {
        child.cancel(false, err)
    }
    c.children = nil                     // 清空子节点引用
    c.mu.Unlock()

    if removeFromParent {
        removeChild(c.Context, c)        // 从父节点 children 中删除自己
    }
}

注意这几点:

  1. 幂等性:多次调用 cancel 只有第一次生效,后续直接 return。
  2. 广播机制close(c.done) 是核心,所有监听 <-ctx.Done() 的 goroutine 会立即收到零值,从而退出。
  3. 递归取消:先取消自己,再取消所有子节点,深度优先。
  4. removeFromParent=false 的巧妙设计:当父取消子时,传 false 表示"你正在遍历 children map,不要同时修改它"。只有主动调用返回的 cancelFunc 时才传 true,把自己从父节点删除。

WithTimeout 内部原理

WithTimeout 本质是 WithDeadline 的语法糖:

func WithTimeout(parent Context, timeout time.Duration) (Context, CancelFunc) {
    return WithDeadline(parent, time.Now().Add(timeout))
}

func WithDeadline(parent Context, d time.Time) (Context, CancelFunc) {
    // 如果父节点的 deadline 比本节点更早,退化为 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()
    if c.err == nil {
        // ★ 使用 time.AfterFunc 设置定时器,到点自动取消
        c.timer = time.AfterFunc(dur, func() {
            c.cancel(true, DeadlineExceeded)
        })
    }
    c.mu.Unlock()
    return c, func() { c.cancel(true, Canceled) }
}

这里的 优化细节 值得注意:

  • 父 deadline 更早:如果父 Context 的 deadline 比当前早,直接退回 WithCancel,因为父节点到点取消时会级联取消子节点,无需重复设置定时器。
  • time.AfterFunc:到点后自动在独立 goroutine 中执行 c.cancel(),触发整棵子树的级联取消。
  • 立即过期:如果 dur <= 0,立刻 cancel,不浪费资源。

深入细节

陷阱一:defer cancel() 的正确时机

这是最常犯的错误。

// ❌ 错误:context 还没用完就 cancel 了
func handleRequest() {
    ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
    defer cancel()           // 函数返回时取消
    result, err := queryDB(ctx)
    // 此时 queryDB 可能还在异步跑,但 ctx 已被取消
}

对于同步操作,defer cancel() 是安全的,因为 queryDB 在函数返回前已经完成。但如果你启动了一个 goroutine:

// ✅ 正确:让 goroutine 持有 ctx,主函数不直接 defer cancel
func handleRequest() {
    ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
    // 启动后台 goroutine
    go doHeavyWork(ctx)

    // 注意:这里不能简单 defer cancel()
    // 必须在主逻辑结束后才取消,以免过早杀死 goroutine
    result := doMainWork(ctx)
    // 主工作完成,可以通知后台 goroutine 退出了
    cancel()
}

核心原则:谁创建 cancelFunc,谁负责调用。但如果 goroutine 的生命周期比主函数长,不能在主函数开始处就 defer cancel(),否则 goroutine 刚开始做就被取消了。

陷阱二:WithTimeout 和 WithCancel 的嵌套行为

parentCtx, parentCancel := context.WithTimeout(context.Background(), 5*time.Second)
defer parentCancel()

// 子 context 的超时比父更长 —— 实际上以父为准
childCtx, childCancel := context.WithTimeout(parentCtx, 10*time.Second)
defer childCancel()

// childCtx 实际会在 5 秒内被取消(父超时),
// 而不是 10 秒。因为 propagateCancel 中 WithDeadline 会检查
// parent.Deadline(),如果比当前更早,退化回 WithCancel

陷阱三:goroutine 泄漏检测

这是一个真实的泄漏场景:

// ❌ 会造成 goroutine 泄漏
func leakyOperation(ctx context.Context) {
    // 模拟一个耗时操作
    go func() {
        time.Sleep(10 * time.Second)  // 即使 ctx 取消了,这行还在跑
        fmt.Println("done")
    }()

    select {
    case <-ctx.Done():
        return
    }
}

为什么泄漏?因为启动的 goroutine 并没有监听 ctx.Done()。正确的做法是每个 goroutine 内部都要用 select 监听 ctx 取消信号

// ✅ 不会泄漏:goroutine 内部监听取消
func safeOperation(ctx context.Context) {
    go func() {
        select {
        case <-time.After(10 * time.Second):
            fmt.Println("work done")
        case <-ctx.Done():   // 收到取消信号立即退出
            fmt.Println("cancelled:", ctx.Err())
            return
        }
    }()
}

与其它语言的对比

语言/框架 超时控制方式 取消传播
Go (Context) WithTimeout / WithDeadline 树形级联取消,自动传播
Java (CompletableFuture) orTimeout() / get(timeout) 需手动调用 cancel()
Python (asyncio) asyncio.wait_for() Task.cancel() 抛出 CancelledError
Node.js (AbortController) AbortSignal.timeout() 手动触发 abort()

Go Context 的独特之处在于:取消信号是树形结构化、自动级联传播的,不需要每个调用方都手动传递取消信号。


最佳实践

1. 始终把 Context 作为第一个参数

Go 官方的约定:Context 必须是函数签名的第一个参数,命名为 ctx

// ✅ 正确的风格
func QueryUser(ctx context.Context, id string) (*User, error)

// ❌ 错误的风格
func QueryUser(id string, ctx context.Context) (*User, error)

2. 为每个外来请求创建带超时的 Context

func HTTPHandler(w http.ResponseWriter, r *http.Request) {
    // 从请求中继承 ctx,加上超时
    ctx, cancel := context.WithTimeout(r.Context(), 2*time.Second)
    defer cancel()  // 函数返回时清理资源

    result, err := queryDatabase(ctx, "SELECT ...")
    if err != nil {
        // 判断是否是超时错误
        if errors.Is(err, context.DeadlineExceeded) {
            w.WriteHeader(http.StatusGatewayTimeout)
            return
        }
        w.WriteHeader(http.StatusInternalServerError)
        return
    }
    json.NewEncoder(w).Encode(result)
}

3. 在慢调用中透传 Context

// db 查询 —— 通过 context 控制超时
func queryDatabase(ctx context.Context, sql string) (*Result, error) {
    // 大多数 Go 数据库驱动(pgx、go-sql-driver/mysql 等)都支持 context
    rows, err := db.QueryContext(ctx, sql)
    if err != nil {
        return nil, fmt.Errorf("query failed: %w", err)
    }
    defer rows.Close()
    // ...
}

// 调用外部 HTTP API —— 也透传 context
func callInventoryAPI(ctx context.Context, sku string) (*Inventory, error) {
    req, _ := http.NewRequestWithContext(ctx, http.MethodGet,
        "https://inv.example.com/"+sku, nil)
    resp, err := http.DefaultClient.Do(req)
    if err != nil {
        return nil, err
    }
    defer resp.Body.Close()
    // ...
}

4. 使用 errors.Is 判断取消原因

select {
case <-ctx.Done():
    switch {
    case errors.Is(ctx.Err(), context.Canceled):
        log.Println("手动取消")
    case errors.Is(ctx.Err(), context.DeadlineExceeded):
        log.Println("超时取消")
    }
case result := <-resultCh:
    return result, nil
}

5. 完整生产级示例

package main

import (
    "context"
    "errors"
    "fmt"
    "math/rand"
    "time"
)

// 模拟下游服务调用
func slowService(ctx context.Context, name string) (string, error) {
    // 模拟随机延迟 0~5 秒
    delay := time.Duration(rand.Intn(5000)) * time.Millisecond

    select {
    case <-time.After(delay):
        return fmt.Sprintf("%s 返回结果 (耗时 %v)", name, delay), nil
    case <-ctx.Done():
        // 收到取消信号,立即退出
        return "", fmt.Errorf("%s 被取消: %w", name, ctx.Err())
    }
}

func main() {
    // 设置总超时 3 秒
    ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
    defer cancel()

    // 并发调用两个服务
    type result struct {
        name string
        val  string
        err  error
    }
    ch := make(chan result, 2)

    go func() {
        val, err := slowService(ctx, "用户服务")
        ch <- result{"用户服务", val, err}
    }()
    go func() {
        val, err := slowService(ctx, "订单服务")
        ch <- result{"订单服务", val, err}
    }()

    // 收集结果(有多少收多少)
    for i := 0; i < 2; i++ {
        r := <-ch
        if r.err != nil {
            if errors.Is(r.err, context.DeadlineExceeded) {
                fmt.Printf("[超时] %s: %v\n", r.name, r.err)
            } else {
                fmt.Printf("[错误] %s: %v\n", r.name, r.err)
            }
            continue
        }
        fmt.Printf("[成功] %s: %s\n", r.name, r.val)
    }
}

运行结果示例(延迟随机):

[成功] 用户服务: 用户服务 返回结果 (耗时 1.2s)
[超时] 订单服务: 订单服务 被取消: context deadline exceeded

总结

  1. Context 的本质:是一个树形传递取消信号的机制,通过 propagateCancel 将子节点挂到父节点的 children map 上,父取消时递归关闭所有子节点的 done channel,实现级联取消。

  2. WithTimeout 的实现:内部使用 time.AfterFunc 设置定时器,到点自动调用 cancel();如果父 deadline 更早则退化为 WithCancel,避免重复设 timer。

  3. 正确使用的关键:每个 goroutine 都要通过 select + <-ctx.Done() 响应取消信号;defer cancel() 的时机取决于 goroutine 的生命周期;始终将 Context 作为第一个参数传递。


延伸阅读方向

  1. Go 1.21 新增的 context.WithCancelCause — 允许在取消时传递具体原因,通过 context.Cause(ctx) 获取。
  2. context.AfterFunc — Go 1.21 引入,在 Context 取消后执行回调,替代手动启动 goroutine 监听。
  3. 对比研究:gRPC 如何利用 Context 取消传播 — 看 gRPC 拦截器如何自动在 RPC 调用链中传递 deadline。