Contents

Go Context 机制详解:超时控制、取消传播与避坑指南

为什么 Context 是 Go 的灵魂

如果你写过 Go,一定见过函数签名里第一个参数永远是 ctx context.Context。这不是约定俗成的美观——Context 是 Go 处理并发控制的核心机制,它解决了三个关键问题:

  1. 超时控制:请求处理到一半超时了怎么办?
  2. 取消传播:客户端断开连接后,服务端如何停止所有下游工作?
  3. 值传递:traceID、userID 等请求级数据如何穿透调用链?

但很多开发者只是把 Context 当传声筒,从不真正使用它的控制能力。今天我们来彻底搞懂它。

超时控制:WithTimeout 与 WithDeadline

基本用法

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
func fetchData(ctx context.Context) (string, error) {
    ctx, cancel := context.WithTimeout(ctx, 3*time.Second)
    defer cancel() // 必须调用,否则资源泄漏

    req, _ := http.NewRequestWithContext(ctx, "GET", "https://api.example.com/data", nil)
    resp, err := http.DefaultClient.Do(req)
    if err != nil {
        if errors.Is(err, context.DeadlineExceeded) {
            return "", fmt.Errorf("请求超时: %w", err)
        }
        return "", err
    }
    defer resp.Body.Close()

    body, _ := io.ReadAll(resp.Body)
    return string(body), nil
}

关键点:cancel() 必须用 defer 调用。即使函数提前 return,也要释放 Context 占用的资源。WithTimeout 本质是 WithDeadline(parent, time.Now().Add(d)) 的快捷方式——一个指定时长,一个指定截止时刻。

嵌套超时:子 Context 的传播规则

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
func handleRequest(ctx context.Context) error {
    // 父 Context 超时 5 秒
    ctx, cancel := context.WithTimeout(ctx, 5*time.Second)
    defer cancel()

    // 子 Context 超时 2 秒——子先到期
    dbCtx, dbCancel := context.WithTimeout(ctx, 2*time.Second)
    defer dbCancel()

    return queryDB(dbCtx)
}

规则很简单:子的超时不能超过父的超时。如果父 Context 在 3 秒后被取消,子 Context 设的 5 秒超时毫无意义——它会在 3 秒时随父级一起取消。这就是取消传播的基础。

取消传播:WithCancel 的正确姿势

经典场景:客户端断开连接

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
func slowOperation(ctx context.Context) error {
    select {
    case <-time.After(10 * time.Second):
        return nil
    case <-ctx.Done():
        return ctx.Err() // 返回 context.Canceled
    }
}

func handler(w http.ResponseWriter, r *http.Request) {
    ctx := r.Context() // HTTP 请求自带 Context

    err := slowOperation(ctx)
    if err != nil {
        if errors.Is(err, context.Canceled) {
            log.Println("客户端已断开,停止处理")
            return
        }
        http.Error(w, err.Error(), 500)
    }
    w.Write([]byte("done"))
}

当客户端关闭连接时,r.Context() 会自动触发 Done() channel。slowOperation 中的 select 会立刻收到信号并退出——而不是傻等 10 秒。这就是 Context 最核心的价值。

手动取消:并行查询取最快的

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
func fastestResult(ctx context.Context) (string, error) {
    ctx, cancel := context.WithCancel(ctx)
    defer cancel()

    results := make(chan string, 3)

    for _, source := range []string{"api1", "api2", "api3"} {
        go func(s string) {
            data, err := querySource(ctx, s)
            if err == nil {
                select {
                case results <- data:
                case <-ctx.Done():
                }
            }
        }(source)
    }

    select {
    case res := <-results:
        cancel() // 拿到第一个结果,取消其余查询
        return res, nil
    case <-ctx.Done():
        return "", ctx.Err()
    }
}

拿到第一个结果后调用 cancel(),其余 goroutine 的 ctx.Done() 会立刻触发,它们会退出 select 并停止工作。没有 Context,你很难优雅地实现这个模式。

值传递:WithValue 的能与不能

正确用法:请求级元数据

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
type contextKey string

const traceIDKey contextKey = "trace_id"

func WithTraceID(ctx context.Context, traceID string) context.Context {
    return context.WithValue(ctx, traceIDKey, traceID)
}

func TraceID(ctx context.Context) string {
    if v, ok := ctx.Value(traceIDKey).(string); ok {
        return v
    }
    return "unknown"
}

三条避坑规则

规则一:key 必须用自定义类型。 用裸 string 做 key 会导致跨包冲突——一个包写入 context.WithValue(ctx, "id", x),另一个包读 ctx.Value("id") 可能拿到意料之外的值。用自定义 contextKey 类型从根本上避免。

规则二:不要通过 Context 传业务参数。 函数需要的参数,明明白白写在参数列表里。Context 传值只用于跨层的基础设施数据(traceID、认证信息、日志字段),不传业务逻辑需要的数据。

规则三:WithValue 不影响取消传播。 WithValue 创建的子 Context 会继承父 Context 的取消信号,但不会创建新的取消能力——它只是携带额外数据的管道。

常见陷阱速查表

陷阱 后果 修复方式
忘记 defer cancel() Context 泄漏,定时器不释放 WithTimeout/WithCancel 后紧跟 defer cancel()
string 做 key 值被意外覆盖 用自定义类型 type ctxKey string
把 Context 存进 struct 取消信号不传播 Context 只在函数间传递,绝不存储
不处理 ctx.Done() goroutine 泄漏 所有阻塞操作用 select 监听 ctx.Done()
handler 里用 context.Background() 无法感知客户端断开 HTTP handler 里用 r.Context()

总结

Go Context 的设计哲学是:并发可以创建,但必须有可控的终止机制。三个能力对应三组 API:

  • WithTimeout / WithDeadline → 超时控制
  • WithCancel → 手动取消
  • WithValue → 请求级数据传递

把它们用对,你的 Go 服务在超时、重试、客户端断连等场景下就不会泄漏 goroutine、不会空等请求、不会丢失 trace 信息。这是写出可靠 Go 服务的基本功。