3. context 最佳实践与并发模式

前两篇讲了 API 和机制。这一篇讲真正决定代码质量的两件事:怎么用才不会踩坑,以及两个值得刻进肌肉记忆的并发模式。

七条最佳实践

1. context 作为函数第一个参数

// ✅ 正确
func DoSomething(ctx context.Context, userID int, name string) error { ... }

// ❌ 错误
func DoSomething(userID int, ctx context.Context, name string) error { ... } // 位置错乱
func DoSomething(ctx context.Context) error { ... }                          // 把所有参数塞进 ctx

约定俗成的位置是第一个参数,名字约定为 ctx。go vet 会检查这条约定。

2. 不要在结构体里存 context

// ❌ 错误:context 的生命周期会和结构体绑死
type Worker struct {
    ctx context.Context
    // ...
}

// ✅ 正确:每次调用时把 ctx 作为参数传入
type Worker struct { ... }
func (w *Worker) Run(ctx context.Context) error { ... }

context 是请求级别的,请求结束就该被丢弃。存在结构体里,等于让它的生命周期变成结构体的生命周期,最后会指向一个已经取消的 context。

3. 不要传 nil,用 TODO

// ❌ 错误
func bad(ctx context.Context) {
    if ctx == nil {
        ctx = context.Background() // 不安全,nil 检查在某些实现里会 panic
    }
}

// ✅ 正确:调用方不知道传什么,就用 TODO
func caller() {
    bad(context.TODO())
}

实际上 context.Context 接口不能是 nil(会被实现层判断),传 nil 是 bug。如果没有 context,用 TODO() 占位。

4. WithValue 的 key 用自定义类型

// ❌ 错误
ctx := context.WithValue(ctx, "user_id", 42)

// ✅ 正确
type contextKey string
const keyUserID contextKey = "user_id"
ctx := context.WithValue(ctx, keyUserID, 42)

string key 会跨包冲突——两个包都用 "user_id" 互相覆盖。自定义类型即使字面值相同,类型不同也不会冲突:

type myKey string
const k1 myKey = "user_id"
ctx1 := context.WithValue(ctx, k1, "alice")

type anotherKey string
const k2 anotherKey = "user_id"
ctx2 := context.WithValue(ctx1, k2, "bob")

ctx2.Value(k1) // "alice"
ctx2.Value(k2) // "bob" —— 互不覆盖

5. ctx 只传请求级别的元数据

// ❌ 错误:把业务参数塞进 ctx
func bad(ctx context.Context) error {
    userID := ctx.Value("user_id").(int) // 隐式依赖,类型不安全
    return process(userID)
}

// ✅ 正确:业务参数走函数参数
func good(ctx context.Context, userID int) error {
    return process(userID)
}

ctx 只放 trace ID、认证信息、日志字段这类请求级别的元数据。一旦放业务参数,函数签名就丢失了显式契约。

6. 始终调用 cancel,最稳是 defer

ctx, cancel := context.WithTimeout(context.Background(), 1*time.Second)
defer cancel() // 立即 defer,永远会被调用

WithCancel / WithTimeout / WithDeadline 都会分配内部资源(goroutine、定时器),不调用 cancel() 会泄漏。defer cancel() 是最保险的写法——创建后立即 defer。

7. 在循环中要小心 cancel 时机

// ❌ 错误:defer 在函数返回时才执行,循环里每次迭代都泄漏一个 context
for _, item := range items {
    ctx, cancel := context.WithTimeout(parent, 5*time.Second)
    defer cancel() // 直到函数返回才执行!
    process(ctx, item)
}

// ✅ 正确:把循环体抽成函数
for _, item := range items {
    processItem(parent, item)
}

func processItem(parent context.Context, item Item) {
    ctx, cancel := context.WithTimeout(parent, 5*time.Second)
    defer cancel()
    process(ctx, item)
}

// ✅ 或者每次循环显式调用
for _, item := range items {
    ctx, cancel := context.WithTimeout(parent, 5*time.Second)
    process(ctx, item)
    cancel() // 立即调用
}

defer函数返回时执行,不是在结束时执行。循环里的 defer 会堆积,直到函数结束才统一释放,长循环会显著泄漏。

工作池模式

工作池(worker pool)是 Go 里最常见的并发模式:固定数量的 worker goroutine,从共享 channel 取任务处理。配合 context,可以统一控制所有 worker 的生命周期

func workerPool(ctx context.Context, jobs <-chan int) {
    var wg sync.WaitGroup
    for w := 1; w <= 3; w++ {
        wg.Add(1)
        go func(id int) {
            defer wg.Done()
            worker(ctx, id, jobs)
        }(w)
    }
    wg.Wait()
}

func worker(ctx context.Context, id int, jobs <-chan int) {
    for {
        select {
        case <-ctx.Done():
            fmt.Printf("worker %d: 退出\n", id)
            return
        case job, ok := <-jobs:
            if !ok {
                return
            }
            fmt.Printf("worker %d: 处理 %d\n", id, job)
            time.Sleep(100 * time.Millisecond)
        }
    }
}

模式要点:

  1. 所有 worker 共享同一个 ctx
  2. 每个 worker 在 select同时监听 ctx.Done()jobs channel
  3. 调用 cancel() 后,所有 worker 几乎同时收到信号,优雅退出
  4. sync.WaitGroup 等所有 worker 退出后再继续

带超时的结果收集

实际场景:并发调用多个数据源,到点就放弃未完成的。

func fetchAll(ctx context.Context, sources []string) []string {
    resultCh := make(chan string, len(sources))
    var wg sync.WaitGroup

    for _, src := range sources {
        wg.Add(1)
        go func(source string) {
            defer wg.Done()
            if data := fetch(ctx, source); data != "" {
                resultCh <- data
            }
        }(src)
    }

    go func() {
        wg.Wait()
        close(resultCh)
    }()

    var results []string
    for r := range resultCh {
        results = append(results, r)
    }
    return results
}

func fetch(ctx context.Context, source string) string {
    delay := time.Duration(100+source[0]%5*100) * time.Millisecond
    select {
    case <-time.After(delay):
        return fmt.Sprintf("data from %s", source)
    case <-ctx.Done():
        return "" // 超时返回空
    }
}

关键细节:用一个单独的 goroutine 在 wg.Wait() 后关闭 resultCh。这样主循环 for r := range resultCh 能正常退出,不会死锁。

Pipeline 模式

Pipeline:数据流过一系列处理阶段,每个阶段是一个或多个 goroutine。所有阶段共享同一个 context,整体可被取消。

generator → stage1 → stage2 → consumer
    |          |         |         |
    +---ctx.Done() 控制所有阶段退出---+

基本 pipeline

func generate(ctx context.Context, nums ...int) <-chan int {
    out := make(chan int)
    go func() {
        defer close(out)
        for _, n := range nums {
            select {
            case out <- n:
            case <-ctx.Done():
                return
            }
        }
    }()
    return out
}

func square(ctx context.Context, in <-chan int) <-chan int {
    out := make(chan int)
    go func() {
        defer close(out)
        for n := range in {
            select {
            case out <- n * n:
            case <-ctx.Done():
                return
            }
        }
    }()
    return out
}

// 构建流水线
ch1 := generate(ctx, 1, 2, 3, 4, 5)
ch2 := square(ctx, ch1)
for r := range ch2 {
    fmt.Println(r) // 1 4 9 16 25
}

每个阶段的套路:

  1. 接收上游的 <-chan T 作为输入
  2. 启动 goroutine 处理
  3. select 同时尝试写下游 channel 和监听 ctx.Done()
  4. defer close(out)——这是避免下游 goroutine 阻塞的关键

Fan-out / Fan-in

Fan-out:多个 worker 处理同一份输入,提高吞吐。Fan-in:把多个 channel 的结果合并到一个 channel。

// Fan-out:多个 worker 同时消费 in
in := make(chan int, 5)
workers := []<-chan int{
    worker(ctx, 1, in),
    worker(ctx, 2, in),
    worker(ctx, 3, in),
}

// Fan-in:合并多个 channel
merged := merge(ctx, workers...)
for r := range merged {
    fmt.Println(r)
}

func merge(ctx context.Context, channels ...<-chan int) <-chan int {
    out := make(chan int)
    var wg sync.WaitGroup
    for _, ch := range channels {
        wg.Add(1)
        go func(c <-chan int) {
            defer wg.Done()
            for n := range c {
                select {
                case out <- n:
                case <-ctx.Done():
                    return
                }
            }
        }(ch)
    }
    go func() {
        wg.Wait()
        close(out)
    }()
    return out
}

Fan-out 用同一个输入 channel:多个 goroutine 从同一个 channel 读,channel 自动负载均衡。

Fan-in 用 WaitGroup 收尾:所有上游 channel 都关闭后,再关闭下游 channel,避免下游 for-range 死锁。

小结

最佳实践的记忆点可以浓缩成一句话:ctx 是请求级别的、第一位的、显式的、必须被取消的参数

两个并发模式的核心都是 <-ctx.Done() + select

  • Worker pool:多个 worker 抢同一个 channel,context 让它们集体退出
  • Pipeline:数据流过多阶段,每个阶段都监听 context,defer close(out) 是链路畅通的保证