Go Context 超时控制:让 Goroutine 该停就停

Go 0 次阅读
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() 返回取消原因:CanceledDeadlineExceeded
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/sqlnet/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)
    }
}

运行输出(取决于时间):

⏰ 超时了,但已收集到部分结果: [商品服务 返回结果 库存服务 返回结果]

关键点:

  1. 所有 goroutine 共享同一个 ctx
  2. 用带缓冲的 channel 避免 goroutine 泄漏
  3. select 监听 ctx.Done() 实现优雅退出
  4. 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
}

总结

  1. Context 的本质是 goroutine 树的取消信号广播器,通过 channel 关闭 + 递归遍历子节点实现信号的向下传播。它不是直接杀死 goroutine,而是给 goroutine 一个"你应该退出了"的通知。

  2. 用 WithTimeout/WithDeadline 做超时控制,搭配 WithCancel 做手动取消。父子 context 之间的 deadline 会自动取更早的那个,不需要手动做嵌套超时。

  3. 最佳实践三件套:函数签名第一个参数传 ctx → 用 defer cancel() 释放资源 → 在 select 中监听 <-ctx.Done() 实现优雅退出。不要用 Context 传业务参数。

延伸阅读

  • Go 标准库 context 源码——只有 500 行,值得一读
  • 搜索「Go context cancel 树形传播 源码分析」深入了解 propagateCancel 的多种分支情况