2. context 核心机制

上一篇讲了五个创建函数。这一篇讲真正让 context 工作起来的东西:怎么监听取消信号、怎么判断为什么被取消、在 HTTP 链路里怎么传播、以及父子之间的传播规则

select + ctx.Done():最核心的使用模式

ctx.Done() 返回一个 <-chan struct{}未取消时读取会阻塞,取消后通道关闭,读取立即返回零值。这个特性决定了 context 的标准用法是 select

select {
case result := <-resultCh:
    return result, nil
case <-ctx.Done():
    return "", ctx.Err()
}

select 让”等待业务结果”和”等待取消信号”同时进行,谁先到走谁的分支。这是 context 的灵魂,几乎所有用法都是这个模式的变体。

模式一:for-select 循环

长跑型 goroutine,循环里检查取消:

go func() {
    time.Sleep(500 * time.Millisecond)
    cancel()
}()

for i := 1; ; i++ {
    select {
    case <-ctx.Done():
        fmt.Printf("第 %d 次检测到取消,退出\n", i)
        return
    default:
        fmt.Printf("处理任务 %d\n", i)
        time.Sleep(150 * time.Millisecond)
    }
}

注意 default 分支让 select 变成非阻塞——能干活就干活,没活干且收到取消就退出。

模式二:等待结果或取消

封装一个可能超时的操作:

func doWork(ctx context.Context) (string, error) {
    resultCh := make(chan string, 1)
    go func() {
        time.Sleep(200 * time.Millisecond)
        resultCh <- "工作完成"
    }()

    select {
    case r := <-resultCh:
        return r, nil
    case <-ctx.Done():
        return "", ctx.Err()
    }
}

业务 goroutine 把结果发到 channel,主流程在结果和取消之间二选一。关键:channel 一定要有 buffer 或有接收方,否则业务 goroutine 会在 resultCh <- ... 永久阻塞,导致泄漏。

模式三:级联 goroutine

父 context 取消时,所有持有它的 goroutine 同时收到信号:

go func(ctx context.Context) {
    fmt.Println("A 启动")
    go func(ctx context.Context) {
        fmt.Println("B 启动")
        go func(ctx context.Context) {
            fmt.Println("C 启动")
            <-ctx.Done()
            fmt.Println("C 退出")
        }(ctx)
        <-ctx.Done()
        fmt.Println("B 退出")
    }(ctx)
    <-ctx.Done()
    fmt.Println("A 退出")
}(ctx)

time.Sleep(100 * time.Millisecond)
cancel() // A、B、C 同时退出

这正是 context 取消传播的价值:一次 cancel(),整棵 goroutine 树优雅退出

区分 Canceled 与 DeadlineExceeded

context 取消后,ctx.Err() 返回两个标准错误之一:

  • context.Canceled:被主动取消(调用了 cancel()
  • context.DeadlineExceeded:超时自动取消

判断方式有三种,推荐 errors.Is

err := ctx.Err()
fmt.Println(err == context.Canceled)                    // 直接比较
fmt.Println(errors.Is(err, context.Canceled))           // 推荐,支持 Unwrap

为什么强调区分?因为业务上两种错误往往需要不同处理

err := doRequest(ctx)
if errors.Is(err, context.DeadlineExceeded) {
    // 超时:可能只是后端慢,可以重试
    retry()
} else if errors.Is(err, context.Canceled) {
    // 用户主动取消:不要重试,用户已经走开了
    return
}

一个反直觉的点值得记住:WithTimeout 创建的 context,如果被提前 cancel()Err() 返回的是 Canceled 而不是 DeadlineExceeded。错误类型只反映”实际触发取消的原因”。

HTTP 服务里的 context

Go 的 net/http 原生支持 context:

  • r.Context() 返回请求的 context
  • 客户端断开连接时,context 自动取消
  • 中间件可以用 WithValue / WithTimeout 包装请求 context
func handler(w http.ResponseWriter, r *http.Request) {
    ctx := r.Context()

    select {
    case <-time.After(2 * time.Second):
        fmt.Fprintln(w, "处理完成")
    case <-ctx.Done():
        // 客户端断开 → ctx.Done() 关闭
        log.Printf("客户端断开: %v", ctx.Err())
    }
}

这个机制很关键:不需要写心跳检测,HTTP 框架替你做了。客户端断开 → context 取消 → handler 里的 <-ctx.Done() 立即返回,避免服务端空转。

中间件注入值

中间件层层包装 context,handler 在最内层能拿到所有值:

type Middleware func(ctx context.Context) context.Context

logging := func(ctx context.Context) context.Context {
    return context.WithValue(ctx, "trace_id", "trace-abc")
}

auth := func(ctx context.Context) context.Context {
    return context.WithValue(ctx, "user_id", "user-42")
}

ctx := auth(logging(context.Background()))
fmt.Println(ctx.Value("trace_id")) // trace-abc
fmt.Println(ctx.Value("user_id"))  // user-42

数据库查询超时

实际项目里常见的链路:HTTP handler 给整体请求设超时,给 DB 查询设更短的超时。

handlerCtx, _ := context.WithTimeout(context.Background(), 300*time.Millisecond)
dbCtx, _ := context.WithTimeout(handlerCtx, 150*time.Millisecond)

// DB 查询最多 150ms,整体请求最多 300ms
// 即使 handler 还有其他工作,DB 也会在 150ms 后放弃
result, err := queryDB(dbCtx)

传播规则

父子 context 之间的传播规则是 context 包的核心,这五条规则记牢,绝大部分问题都能想清楚

规则 1:取消从父向子(单向)

root, rootCancel := context.WithCancel(context.Background())
child, childCancel := context.WithCancel(root)
grandchild, grandchildCancel := context.WithCancel(child)

rootCancel()

fmt.Println(root.Err(), child.Err(), grandchild.Err())
// canceled canceled canceled

父取消,所有子孙都被取消。反向不成立——子取消不会影响父。

规则 2:子取消不影响父

parent, _ := context.WithCancel(context.Background())
child, childCancel := context.WithCancel(parent)

childCancel()

fmt.Println(parent.Err()) // nil(未受影响)
fmt.Println(child.Err())  // canceled

规则 3:Deadline 取最早的

parent, _ := context.WithTimeout(context.Background(), 200*time.Millisecond)
child1, _ := context.WithTimeout(parent, 50*time.Millisecond)  // 子更短 → 50ms
child2, _ := context.WithTimeout(parent, 500*time.Millisecond) // 父更短 → 200ms

子 context 的 deadline 永远不会晚于父。实际生效的总是更早的那个。这条规则保证子任务不可能超过父任务的生命周期。

规则 4:Context 树结构

一棵 context 树长这样:

          Background
         /          \
    cancel-1      cancel-2
    /     \          |
timeout  value    deadline

取消某个中间节点,它的所有子孙都被取消,但兄弟分支不受影响:

branch1, cancel1 := context.WithCancel(root)
branch2, _ := context.WithCancel(root)
leaf1, _ := context.WithTimeout(branch1, 1*time.Second)
leaf2 := context.WithValue(branch1, "key", "value")
leaf3, _ := context.WithDeadline(branch2, time.Now().Add(1*time.Second))

cancel1()
// branch1.Err() = canceled
// leaf1.Err()   = canceled  (branch1 的子孙)
// leaf2.Err()   = canceled  (branch1 的子孙)
// branch2.Err() = nil       (兄弟分支,不受影响)
// leaf3.Err()   = nil       (branch2 的子孙,不受影响)

规则 5:取消和值可以自由组合

ctx := context.Background()
ctx = context.WithValue(ctx, "trace_id", "trace-001")
ctx, cancel := context.WithCancel(ctx)
defer cancel()
ctx = context.WithValue(ctx, "user_id", "user-123")
ctx2, cancel2 := context.WithTimeout(ctx, 1*time.Second)
defer cancel2()

// ctx2 既有所有值,也有取消,也有超时
fmt.Println(ctx2.Value("trace_id")) // trace-001
fmt.Println(ctx2.Value("user_id"))  // user-123

值链和取消链是两条独立的链,但都从同一个父派生,互不冲突。

小结

核心机制只有四条:

  1. select + ctx.Done() 是 context 的标准用法,业务结果和取消信号二选一。
  2. 错误类型 Canceled / DeadlineExceeded 反映”实际取消原因”,用 errors.Is 判断。
  3. HTTP 框架自动接入:客户端断开 → context 取消,无需自己实现。
  4. 五条传播规则:取消父→子单向、子不影响父、deadline 取最早、树结构局部取消、值链与取消链独立。