4. 并发模式与工程实践

在 Go 的并发工程中,真正决定代码可维护性的往往不是“能不能并发”,而是“并发结构是否清晰、退出是否可控、错误能否传播”。本节围绕四类高频并发模式展开:生产者-消费者、扇入扇出、流水线,以及贯穿所有模式的超时控制与 context 工程化应用。它们不是孤立技巧,而是可以组合成一套稳定的并发骨架。

生产者-消费者:用 channel 解耦速率差异

生产者-消费者模式的核心是:生产者只负责生成数据并写入 channel,消费者只负责从 channel 读取并处理,双方不直接持有对方引用。这样做的工程价值在于解耦生产速率与消费速率,避免上游突发流量直接压垮下游。

一个典型实现是带缓冲的 channel 作为队列。例如任务生产速率为每秒 200 个,消费处理耗时约 8ms,则单消费者理论吞吐约 125 个/秒,队列会持续增长。此时应增加消费者数量或降低生产速率。示例参数如下:

jobs := make(chan Job, 100)   // 缓冲队列,容量 100
for i := 0; i < 4; i++ {      // 4 个消费者
    go func() {
        for job := range jobs {
            process(job)       // 单次约 8ms
        }
    }()
}
for _, j := range allJobs {
    jobs <- j                  // 生产端
}
close(jobs)                    // 生产结束,通知消费者退出

这里的关键判断是:close(jobs) 由生产端在全部写入完成后调用,消费者通过 range 感知 channel 关闭并自然退出。若消费者还需要向更下游发送结果,则不能简单关闭下游 channel,而应使用 sync.WaitGroup 等待所有消费者结束后再关闭,否则会触发“向已关闭 channel 发送”的 panic。

扇入扇出:并行度与结果汇聚

扇出(fan-out)指多个 goroutine 从同一个 channel 读取任务,提升并行处理能力;扇入(fan-in)指多个结果 channel 汇聚到一个 channel,供后续统一处理。二者常配合使用,形成“分发—并行—汇聚”的结构。

工程上需要关注两点:一是扇出数量应与 CPU 核数、I/O 阻塞特性匹配,CPU 密集型任务通常设为 runtime.NumCPU(),I/O 密集型可适当放大;二是扇入时必须保证所有上游 channel 关闭后再关闭汇聚 channel,否则下游会提前退出或阻塞。

流水线:阶段化处理与背压

流水线把处理过程拆成多个阶段,每个阶段是一个 goroutine 或一组 goroutine,阶段之间用 channel 连接。数据像在装配线上一样逐级流动。流水线的优势是各阶段可并行,且天然形成背压:当下游处理慢时,channel 缓冲填满,上游写入阻塞,从而抑制过快生产。

设计流水线时,每个阶段应遵循统一契约:接收输入 channel,返回输出 channel,并在内部启动 goroutine 完成转换。阶段函数不负责关闭输入 channel,关闭责任归上游;输出 channel 由本阶段在输入耗尽后关闭。这样责任边界清晰,避免多处关闭同一 channel。

并发流水线 + 扇出扇入 + context 取消传播 生产者 生成 Job 阶段1:解析 parse → chan A Worker 1 处理 chan A Worker 2 处理 chan A Worker N 处理 chan A 扇入汇聚 chan B → 结果 消费者 / 输出 写库 / 返回 context 取消传播 ctx, cancel := context.WithTimeout(parent, 2*time.Second) select { case <-ctx.Done(): return ctx.Err() // 超时或主动取消,所有阶段统一退出 } Done() 信号

超时控制:select + time.After 与 context 的选择

超时控制有两种常见写法。第一种是 select 配合 time.After:

select {
case res := <-resultCh:
    return res, nil
case <-time.After(500 * time.Millisecond):
    return nil, ErrTimeout
}

这种方式适合单次等待,但 time.After 每次都会创建定时器,在高频循环中可能造成内存压力。第二种是 context.WithTimeout,它把超时作为取消信号向下游传播,适合跨多个函数、多个 goroutine 的调用链。工程上更推荐后者,因为超时不是“某个 select 的局部行为”,而是整个请求生命周期的约束。

context 的工程化应用要点

context 在并发工程中承担三个职责:传递取消信号、传递截止时间、传递请求级元数据。使用时有几条硬性纪律:

  • context 应作为函数的第一个参数,命名为 ctx,不要塞进结构体。
  • 不要传 nil context,不确定时用 context.TODO() 或 context.Background()。
  • 每个 WithCancel、WithTimeout、WithDeadline 都必须调用 cancel,通常用 defer cancel(),防止 goroutine 泄漏。
  • 下游 goroutine 应通过 select 监听 ctx.Done(),而不是只监听数据 channel。
  • 元数据只放请求级小对象,不要放业务大对象或可变状态。

一个常见的错误是:生产者已经退出,但消费者仍在阻塞等待数据,导致 goroutine 永久挂起。正确做法是让所有阻塞点都同时监听 ctx.Done():

for {
    select {
    case <-ctx.Done():
        return ctx.Err()
    case job, ok := <-jobs:
        if !ok {
            return nil
        }
        process(job)
    }
}

组合实践:带超时的扇出流水线

把上述模式组合起来,可以得到一个可复用的工程骨架:主 goroutine 创建带超时的 context,启动生产者、N 个 worker 和汇聚器;任意阶段出错或超时,通过 cancel() 触发全局退出;使用 errgroup 或 WaitGroup 等待所有 goroutine 收敛。判断是否健康的标准是:请求结束后,goroutine 数量回到基线,没有向已关闭 channel 发送,没有遗漏 cancel 调用。

实践建议:先用 go test -race 验证并发安全,再用 runtime.NumGoroutine() 在测试前后对比,确认没有 goroutine 泄漏。超时时间应略大于下游 P99 耗时,而不是拍脑袋设为 1 秒。

总结来看,生产者-消费者解决解耦,扇入扇出解决并行与汇聚,流水线解决阶段化处理,超时与 context 解决“何时停、如何停、停了之后谁负责清理”。把这四者放在同一套退出契约下设计,并发代码才能从“能跑”走向“可维护”。