mobile wallpaper 1mobile wallpaper 2mobile wallpaper 3mobile wallpaper 4
3980 字
11 分钟
Go 并发模式实战
2023-07-16

1. 并发模式概述#

1.1 为什么需要并发模式?#

Go 语言的并发原语(goroutine、channel、select)虽然强大,但直接使用它们构建复杂系统容易出错。常见的并发问题包括:

  • goroutine 泄漏:goroutine 永久阻塞,无法退出
  • 资源竞争:多个 goroutine 并发访问共享资源
  • 错误传播:并发任务的错误如何向上传递
  • 取消与超时:如何优雅地取消正在进行的任务

并发模式是对这些问题的标准化解决方案,它们提供了可复用的设计模板。

1.2 模式分类#

  • 数据流模式
    • Pipeline(流水线)
    • Fan-out/Fan-in(扇出/扇入)
    • Scatter-Gather(分发-聚合)
  • 资源管理模式
    • Worker Pool(工作池)
    • Semaphore(信号量)
    • Bounded Parallelism(有界并行)
  • 控制模式
    • Context Cancellation(上下文取消)
    • Timeout(超时控制)
    • Graceful Shutdown(优雅关闭)
  • 错误处理模式
    • errgroup(错误组)
    • First Error Wins(首个错误胜出)
    • Error Aggregation(错误聚合)

2. Pipeline 模式#

2.1 模式定义#

Pipeline 模式将复杂的数据处理流程分解为多个阶段(Stage),每个阶段通过 channel 连接,数据像流水线一样依次流过各个阶段。

graph LR SRC["数据源"] --> S1["Stage 1<br/>生成器"] S1 -->|"chan int"| S2["Stage 2<br/>转换器"] S2 -->|"chan int"| S3["Stage 3<br/>过滤器"] S3 -->|"chan int"| S4["Stage 4<br/>消费者"] S4 --> OUT["结果"] style SRC fill:#2196F3,color:#fff style S1 fill:#4CAF50,color:#fff style S2 fill:#FF9800,color:#fff style S3 fill:#9C27B0,color:#fff style S4 fill:#F44336,color:#fff style OUT fill:#2196F3,color:#fff

2.2 基础实现#

// Stage 1: 生成器:产生数据源
func generate(nums ...int) <-chan int {
out := make(chan int)
go func() {
defer close(out)
for _, n := range nums {
out <- n
}
}()
return out
}
// Stage 2: 平方运算:转换数据
func square(in <-chan int) <-chan int {
out := make(chan int)
go func() {
defer close(out)
for n := range in {
out <- n * n
}
}()
return out
}
// Stage 3: 过滤:筛选符合条件的值
func filter(in <-chan int, predicate func(int) bool) <-chan int {
out := make(chan int)
go func() {
defer close(out)
for n := range in {
if predicate(n) {
out <- n
}
}
}()
return out
}
// 使用示例
func main() {
// 构建流水线
nums := generate(1, 2, 3, 4, 5, 6, 7, 8, 9, 10)
squares := square(nums)
evens := filter(squares, func(n int) bool { return n > 25 })
// 消费结果
for n := range evens {
fmt.Println(n)
}
}

2.3 可取消的 Pipeline#

实际场景中,Pipeline 需要支持取消操作:

func generateContext(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 squareContext(ctx context.Context, in <-chan int) <-chan int {
out := make(chan int)
go func() {
defer close(out)
for {
select {
case n, ok := <-in:
if !ok {
return
}
select {
case out <- n * n:
case <-ctx.Done():
return
}
case <-ctx.Done():
return
}
}
}()
return out
}

2.4 Pipeline 的优缺点#

优点缺点
阶段解耦,易于测试每个 Stage 创建 goroutine
可组合性强channel 开销
天然支持流式处理背压处理复杂
便于并行化(结合 Fan-out)错误处理需要额外设计

2.5 Pipeline 的背压问题#

Pipeline 的各个 Stage 通过 channel 串联,数据从上游流向下游。当某个 Stage 处理速度慢于上游的生产速度时,上游的 channel 发送操作会阻塞,这种”下游压力向上游传导”的机制就是背压(Backpressure)。

为什么 Pipeline 天然会产生背压?这源于 Go channel 的设计:当发送方往一个满的 channel 写入数据时,发送方所在的 goroutine 会被挂起,直到 channel 有空间。在 Pipeline 中,每个 Stage 的输出 channel 就是下游的输入 channel,下游消费慢导致 channel 填满,上游的发送自然阻塞。无缓冲 channel 提供最严格的背压,因为每次发送都必须等接收方就绪;带缓冲 channel 在缓冲区填满之前不会阻塞上游,相当于延迟了背压的传导。

背压本身不是缺陷,它是 Pipeline 天然的流量调节机制:无缓冲 channel 让每个 Stage 自动限速,生产者不会跑得比消费者更快。问题出在两个场景:

  1. 上游阻塞导致资源浪费:Stage 1 从数据库读取数据,Stage 3 写入远程 API 很慢。Stage 1 的 goroutine 阻塞在 channel 发送上,持有的数据库连接无法释放。
  2. 内存暴涨:如果用带缓冲 channel 试图”缓解”阻塞,缓冲区只是把问题延迟了。生产速度持续高于消费速度时,缓冲区迟早填满,要么回退到阻塞,要么内存溢出。

应对背压的常见策略:

  • 控制并发源头:用 Worker Pool 或信号量限制 Stage 1 的生产速率,从源头控制流量,而不是靠下游反压。这种方式最可靠,因为生产速率是可控的输入变量,下游消费速率是外部约束,调节输入比依赖反馈更直接。
  • 选择合适的 channel 缓冲:无缓冲 channel 提供最严格的背压,但吞吐低;带缓冲 channel 提供弹性空间,但缓冲大小需要根据消费速度和生产速度的差值来估算,不能盲目设大。缓冲大小的选取逻辑是:缓冲区应该能吸收生产与消费之间的短期波动,而不是长期掩盖速率不匹配的问题。一个实用的估算方法是,缓冲大小设为”慢速 Stage 一个处理周期内上游产生的数据量”,这样慢速 Stage 每消费一条数据,上游恰好填入一条,不会长期积压。
  • 主动丢弃:对时效性数据(如实时监控指标),用 select + default 分支在 channel 满时丢弃旧数据,避免阻塞。这种策略只适用于数据有时效性的场景,丢弃过时数据不影响最终结果。
// 主动丢弃策略:channel 满时跳过当前数据
func dropOnFull(in <-chan int) <-chan int {
out := make(chan int, 10) // 有限缓冲
go func() {
defer close(out)
for n := range in {
select {
case out <- n:
default:
// channel 满时丢弃,避免上游阻塞
log.Printf("dropped: %d", n)
}
}
}()
return out
}

3. Fan-out/Fan-in 模式#

3.1 模式定义#

  • Fan-out:多个 goroutine 从同一个 channel 读取数据,并行处理
  • Fan-in:多个 goroutine 的结果合并到一个 channel
graph LR subgraph "Fan-out(扇出)" P["Producer"] -->|"chan Task"| W1["Worker 1"] P -->|"chan Task"| W2["Worker 2"] P -->|"chan Task"| W3["Worker 3"] end subgraph "Fan-in(扇入)" W1 -->|"chan Result"| M["Merger"] W2 -->|"chan Result"| M W3 -->|"chan Result"| M end M --> OUT["结果"] style P fill:#2196F3,color:#fff style W1 fill:#4CAF50,color:#fff style W2 fill:#4CAF50,color:#fff style W3 fill:#4CAF50,color:#fff style M fill:#FF9800,color:#fff style OUT fill:#9C27B0,color:#fff

3.2 Fan-out 实现#

// 并行执行多个 worker
func fanOut(worker func(<-chan Task) <-chan Result, jobs <-chan Task, workers int) []<-chan Result {
results := make([]<-chan Result, workers)
for i := 0; i < workers; i++ {
results[i] = worker(jobs)
}
return results
}
func worker(jobs <-chan Task) <-chan Result {
out := make(chan Result)
go func() {
defer close(out)
for job := range jobs {
out <- process(job)
}
}()
return out
}

3.3 Fan-in 实现#

Fan-in 的核心依赖 sync.WaitGroup 来同步多个 goroutine 的完成状态。

// 合并多个 channel 的结果
func fanIn(ctx context.Context, channels ...<-chan Result) <-chan Result {
out := make(chan Result)
var wg sync.WaitGroup
wg.Add(len(channels))
// 为每个输入 channel 启动一个 goroutine
for _, ch := range channels {
go func(c <-chan Result) {
defer wg.Done()
for result := range c {
select {
case out <- result:
case <-ctx.Done():
return
}
}
}(ch)
}
// 等待所有 channel 关闭后关闭输出
go func() {
wg.Wait()
close(out)
}()
return out
}

3.4 完整示例:并行 URL 抓取#

func fetchURLs(urls []string) ([]Page, error) {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
// 创建任务 channel
jobs := make(chan string, len(urls))
for _, url := range urls {
jobs <- url
}
close(jobs)
// Fan-out:启动多个 worker 并行抓取
numWorkers := min(5, len(urls))
results := fanOut(fetchWorker, jobs, numWorkers)
// Fan-in:合并结果
merged := fanIn(ctx, results...)
// 收集结果
var pages []Page
for result := range merged {
if result.Err != nil {
return nil, result.Err
}
pages = append(pages, result.Page)
}
return pages, nil
}
func fetchWorker(jobs <-chan string) <-chan FetchResult {
out := make(chan FetchResult)
go func() {
defer close(out)
client := http.Client{Timeout: 10 * time.Second}
for url := range jobs {
resp, err := client.Get(url)
if err != nil {
out <- FetchResult{Err: err}
continue
}
body, _ := io.ReadAll(resp.Body)
resp.Body.Close()
out <- FetchResult{Page: Page{URL: url, Content: string(body)}}
}
}()
return out
}

4. Worker Pool 模式#

4.1 模式定义#

Worker Pool 维护一组固定数量的 worker goroutine,从任务队列中获取任务执行。它限制了并发数量,避免资源耗尽。

为什么用固定数量的 worker 而不是按需创建?Go 的 goroutine 确实很轻量(初始栈仅 2KB),但这不意味着可以无限创建。每个 goroutine 都会占用内存(栈空间会随调用深度增长),持有外部资源(数据库连接、文件句柄),并增加调度器的压力。当并发 goroutine 数量远超 GOMAXPROCS 时,运行时需要在少量操作系统线程上频繁切换 goroutine,调度开销会吃掉本该用于实际工作的 CPU 时间。Worker Pool 用固定数量的 worker 把并发度控制在可预测的范围内,资源消耗有上限,调度压力也可控。

Worker 数量是 Worker Pool 最关键的设计参数,选错了要么浪费资源,要么吞吐上不去。选择依据取决于任务类型:

  • CPU 密集型任务(加密、压缩、图像处理):worker 数设为 runtime.NumCPU()。超过 CPU 核心数的 goroutine 只会在核心间频繁切换,增加调度开销而不提升吞吐。Go 的 GOMAXPROCS 默认等于 CPU 核心数,所以 CPU 密集型任务的并行度上限就是核心数。
  • IO 密集型任务(HTTP 请求、数据库查询、文件读写):worker 数可以是核心数的 2-10 倍。IO 等待期间 goroutine 会让出线程,其他 goroutine 可以继续执行,所以并发度可以远高于核心数。具体倍数取决于 IO 等待占比:等待时间越长,可以填的 goroutine 越多。
  • 混合型任务:先按 CPU 密集型估算基准,再通过基准测试逐步上调。观察 CPU 利用率和吞吐量的关系,CPU 利用率接近 100% 时说明已经到上限。
Tip

以上都是经验起点,实际值必须通过基准测试确认。不同任务的 IO/CPU 比例差异很大,拍脑袋的数字往往不准。

graph TD IN["任务输入"] --> TQ["任务队列<br/>chan Task"] TQ -->|"分发"| W1["Worker 1<br/>goroutine"] TQ -->|"分发"| W2["Worker 2<br/>goroutine"] TQ -->|"分发"| W3["Worker N<br/>goroutine"] W1 --> RQ["结果队列<br/>chan Result"] W2 --> RQ W3 --> RQ RQ --> OUT["结果输出"] style IN fill:#2196F3,color:#fff style TQ fill:#FF9800,color:#fff style W1 fill:#4CAF50,color:#fff style W2 fill:#4CAF50,color:#fff style W3 fill:#4CAF50,color:#fff style RQ fill:#FF9800,color:#fff style OUT fill:#9C27B0,color:#fff

4.2 基础实现#

type WorkerPool struct {
tasks chan Task
results chan Result
workers int
wg sync.WaitGroup
}
func NewWorkerPool(workers, taskCapacity int) *WorkerPool {
return &WorkerPool{
tasks: make(chan Task, taskCapacity),
results: make(chan Result, taskCapacity),
workers: workers,
}
}
func (p *WorkerPool) Start() {
for i := 0; i < p.workers; i++ {
p.wg.Add(1)
go p.worker(i)
}
}
func (p *WorkerPool) worker(id int) {
defer p.wg.Done()
for task := range p.tasks {
result := process(task)
p.results <- result
}
}
func (p *WorkerPool) Submit(task Task) {
p.tasks <- task
}
func (p *WorkerPool) Stop() {
close(p.tasks)
p.wg.Wait()
close(p.results)
}
func (p *WorkerPool) Results() <-chan Result {
return p.results
}

4.3 支持取消的 Worker Pool#

type CancellableWorkerPool struct {
tasks chan Task
results chan Result
workers int
ctx context.Context
cancel context.CancelFunc
wg sync.WaitGroup
}
func NewCancellableWorkerPool(ctx context.Context, workers, capacity int) *CancellableWorkerPool {
childCtx, cancel := context.WithCancel(ctx)
return &CancellableWorkerPool{
tasks: make(chan Task, capacity),
results: make(chan Result, capacity),
workers: workers,
ctx: childCtx,
cancel: cancel,
}
}
func (p *CancellableWorkerPool) Start() {
for i := 0; i < p.workers; i++ {
p.wg.Add(1)
go p.worker(i)
}
}
func (p *CancellableWorkerPool) worker(id int) {
defer p.wg.Done()
for {
select {
case task, ok := <-p.tasks:
if !ok {
return
}
result := p.processWithCancel(task)
select {
case p.results <- result:
case <-p.ctx.Done():
return
}
case <-p.ctx.Done():
return
}
}
}
func (p *CancellableWorkerPool) processWithCancel(task Task) Result {
// 在处理过程中也检查取消信号
done := make(chan Result, 1)
go func() {
done <- process(task)
}()
select {
case result := <-done:
return result
case <-p.ctx.Done():
return Result{Err: p.ctx.Err()}
}
}
func (p *CancellableWorkerPool) Cancel() {
p.cancel()
}
func (p *CancellableWorkerPool) Stop() {
close(p.tasks)
p.wg.Wait()
close(p.results)
}

4.4 Worker Pool vs 无限制 Goroutine#

// 危险:无限制创建 goroutine
func dangerous(urls []string) {
for _, url := range urls {
go fetch(url) // 可能创建数千个 goroutine
}
}
// 安全:使用 Worker Pool 限制并发
func safe(urls []string, maxWorkers int) {
pool := NewWorkerPool(maxWorkers, len(urls))
pool.Start()
for _, url := range urls {
pool.Submit(Task{URL: url})
}
go func() {
pool.Stop()
}()
for result := range pool.Results() {
// 处理结果
}
}

5. Context 上下文传递与取消#

5.1 Context 接口#

type Context interface {
// 返回 context 被取消的截止时间
Deadline() (deadline time.Time, ok bool)
// 返回一个 channel,当 context 被取消时关闭
Done() <-chan struct{}
// 返回取消原因
Err() error
// 获取与 context 关联的值
Value(key any) any
}

5.2 Context 树形结构#

graph TD BG["context.Background()"] --> VT["WithValue<br/>parent, k, v"] VT --> TO["WithTimeout<br/>3s"] VT --> CA["WithCancel"] VT --> DL["WithDeadline<br/>deadline"] TO --> T1["子任务 1"] CA --> T2["子任务 2"] DL --> T3["子任务 3"] CA -.->|"cancel() 传播"| T2 CA -.->|"cancel() 传播"| T1 CA -.->|"cancel() 传播"| T3 style BG fill:#2196F3,color:#fff style VT fill:#607D8B,color:#fff style TO fill:#FF9800,color:#fff style CA fill:#F44336,color:#fff style DL fill:#9C27B0,color:#fff style T1 fill:#4CAF50,color:#fff style T2 fill:#4CAF50,color:#fff style T3 fill:#4CAF50,color:#fff

当父 Context 取消时,所有子 Context 都会被取消

5.3 Context 取消传播#

func operation(ctx context.Context) error {
// 启动子任务
ctx, cancel := context.WithCancel(ctx)
defer cancel() // 确保资源释放
// 启动多个子任务
results := make(chan Result, 3)
for i := 0; i < 3; i++ {
go func(id int) {
results <- subOperation(ctx, id)
}(i)
}
// 等待第一个结果或取消
select {
case result := <-results:
cancel() // 取消其他子任务
return result.Err
case <-ctx.Done():
return ctx.Err()
}
}
func subOperation(ctx context.Context, id int) Result {
// 定期检查取消信号
for i := 0; i < 100; i++ {
select {
case <-ctx.Done():
return Result{Err: ctx.Err()}
default:
// 执行工作
time.Sleep(10 * time.Millisecond)
}
}
return Result{Value: id}
}

5.4 Context 使用原则#

// 正确:将 Context 作为第一个参数
func DoSomething(ctx context.Context, arg Arg) error {
// ...
}
// 错误:将 Context 放在其他位置
func DoSomething(arg Arg, ctx context.Context) error {
// ...
}
// 正确:不要将 Context 存储在结构体中(少数例外:HTTP Handler)
type Handler struct {
// ctx 不应该作为字段
}
// 正确:context.Background() 作为根 Context
ctx := context.Background()
// 正确:传递取消原因
func process(ctx context.Context) error {
ctx, cancel := context.WithCancelCause(ctx)
go func() {
if err := checkSomething(); err != nil {
cancel(fmt.Errorf("check failed: %w", err))
}
}()
<-ctx.Done()
return context.Cause(ctx) // 获取具体的取消原因
}

6. 超时控制#

6.1 使用 context.WithTimeout#

func fetchWithTimeout(ctx context.Context, url string) (*Response, error) {
// 创建带超时的 context
ctx, cancel := context.WithTimeout(ctx, 5*time.Second)
defer cancel()
req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
if err != nil {
return nil, err
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
if errors.Is(err, context.DeadlineExceeded) {
return nil, fmt.Errorf("request timed out: %w", err)
}
return nil, err
}
return resp, nil
}

6.2 使用 time.After 实现简单超时#

func simpleTimeout(timeout time.Duration) error {
result := make(chan error, 1)
go func() {
result <- doWork()
}()
select {
case err := <-result:
return err
case <-time.After(timeout):
return fmt.Errorf("operation timed out after %v", timeout)
}
}

6.3 超时与取消的区别#

graph TD START["开始"] --> EXEC["执行任务"] EXEC --> DONE["完成"] EXEC -->|"超过指定时间"| TIMEOUT["返回 DeadlineExceeded 错误"] EXEC -->|"调用 cancel()"| CANCEL["返回 Canceled 错误"] style START fill:#2196F3,color:#fff style EXEC fill:#4CAF50,color:#fff style DONE fill:#4CAF50,color:#fff style TIMEOUT fill:#FF9800,color:#fff style CANCEL fill:#F44336,color:#fff

超时和取消的触发方式不同,但底层机制相同:都是通过 Context 的 Done() channel 通知。context.WithTimeout 在时间到达后自动调用 cancel()context.WithCancel 需要调用方手动触发。错误类型不同(DeadlineExceeded vs Canceled),方便调用方区分是超时还是主动取消。

6.4 多层超时控制#

func multiLayerTimeout() error {
// 外层:总超时 30 秒
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
// 阶段 1:数据库查询,超时 10 秒
dbCtx, dbCancel := context.WithTimeout(ctx, 10*time.Second)
data, err := queryDB(dbCtx)
dbCancel()
if err != nil {
return fmt.Errorf("database query: %w", err)
}
// 阶段 2:外部 API 调用,超时 15 秒
apiCtx, apiCancel := context.WithTimeout(ctx, 15*time.Second)
result, err := callAPI(apiCtx, data)
apiCancel()
if err != nil {
return fmt.Errorf("api call: %w", err)
}
return nil
}

7. errgroup 并发错误处理#

7.1 golang.org/x/sync/errgroup#

errgroup 提供了并发任务组的管理,当任一任务出错时,其他任务会被取消。

import "golang.org/x/sync/errgroup"
func fetchAll(urls []string) ([]Page, error) {
g, ctx := errgroup.WithContext(context.Background())
pages := make([]Page, len(urls))
for i, url := range urls {
i, url := i, url // 捕获循环变量
g.Go(func() error {
// 如果其他 goroutine 出错,ctx 会被取消
page, err := fetchPage(ctx, url)
if err != nil {
return err
}
pages[i] = page
return nil
})
}
// 等待所有任务完成,返回第一个错误(如果有)
if err := g.Wait(); err != nil {
return nil, err
}
return pages, nil
}

7.2 errgroup 工作原理#

errgroup 的核心设计决策是”首个错误胜出”(First Error Wins):当多个 goroutine 并发执行时,g.Wait() 只返回第一个出错的 goroutine 的错误,后续错误会被丢弃。

为什么只保留第一个错误?因为并发场景下,多个任务可能因同一个根因而失败(比如共享的数据库连接断开),此时返回一堆重复的错误信息没有价值。第一个错误通常包含最直接的根因,后续错误多半是连锁反应。如果确实需要收集所有错误,errgroup 本身不提供这个能力,需要自行用 channel 或 slice 收集,然后配合 sync.WaitGroup 管理。errgroup 选择”首个错误胜出”是在简洁性和实用性之间做的权衡:大多数并发场景只需要知道”出错了”和”第一个错误是什么”,全量错误收集是少数需求,不值得作为默认行为增加 API 复杂度。

为什么取消是自动的而不是手动的?errgroup 的设计假设是:并发任务组是一个整体,任一任务失败意味着整个任务组的结果不可用。如果出错后不取消其他任务,它们会继续消耗资源做无用功。自动取消让调用方不需要在每个 goroutine 里手动检查其他任务的状态,降低了使用复杂度。但这个假设不适用于所有场景:如果任务之间完全独立,一个失败不影响其他任务的结果,errgroup 的自动取消反而会过早终止本可以成功的任务。这种场景应该用 sync.WaitGroup 配合独立的错误收集。

errgroup.WithContext 的取消行为遵循一个明确的传播路径:

  1. 调用 g.Go(fn) 启动多个 goroutine,它们共享同一个 ctx
  2. 任一 goroutine 返回非 nil 错误时,errgroup 内部调用 cancel() 取消该 ctx
  3. 其他正在运行的 goroutine 通过 ctx.Done() 感知到取消信号,提前退出
  4. g.Wait() 等待所有 goroutine 结束后,返回第一个错误
graph TD G["errgroup.Group"] -->|"g.Go()"| T1["Task 1 成功"] G -->|"g.Go()"| T2["Task 2 出错"] G -->|"g.Go()"| T3["Task 3 运行中"] T2 -->|"返回 error"| CANCEL["cancel() 被调用"] CANCEL -->|"ctx.Done() 传播"| T3 CANCEL -->|"ctx.Done() 传播"| T1 T1 --> WAIT["g.Wait()"] T2 --> WAIT T3 -->|"提前退出"| WAIT WAIT -->|"返回第一个 error"| RESULT["error from Task 2"] style G fill:#2196F3,color:#fff style T1 fill:#4CAF50,color:#fff style T2 fill:#F44336,color:#fff style T3 fill:#FF9800,color:#fff style CANCEL fill:#9C27B0,color:#fff style WAIT fill:#607D8B,color:#fff style RESULT fill:#F44336,color:#fff
Note

errgroup 的取消是”尽力而为”的:它只能取消通过 ctx.Done() 主动检查取消信号的 goroutine。如果 goroutine 内部不检查 ctx,取消不会生效。所以 g.Go 里的函数必须尊重传入的 ctx,在长时间操作中定期检查 ctx.Done()

7.3 带并发限制的 errgroup#

func fetchWithLimit(urls []string, maxConcurrent int) ([]Page, error) {
g, ctx := errgroup.WithContext(context.Background())
// 使用信号量限制并发数
sem := make(chan struct{}, maxConcurrent)
pages := make([]Page, len(urls))
for i, url := range urls {
i, url := i, url
sem <- struct{}{} // 获取信号量
g.Go(func() error {
defer func() { <-sem }() // 释放信号量
page, err := fetchPage(ctx, url)
if err != nil {
return err
}
pages[i] = page
return nil
})
}
if err := g.Wait(); err != nil {
return nil, err
}
return pages, nil
}

7.4 errgroup vs sync.WaitGroup#

特性sync.WaitGrouperrgroup
错误处理需要手动实现自动收集首个错误
取消机制自动取消其他任务
使用复杂度较高较低
Context 集成需要手动集成原生支持
适用场景无需错误处理需要错误处理和取消

8. 资源泄漏防护#

8.1 goroutine 泄漏检测#

使用 runtime 监控 goroutine 数量:

func monitorGoroutines() {
ticker := time.NewTicker(5 * time.Second)
for range ticker.C {
count := runtime.NumGoroutine()
log.Printf("current goroutines: %d", count)
if count > 100 {
log.Printf("WARNING: too many goroutines!")
}
}
}

8.2 常见泄漏场景及修复#

// 场景 1:无缓冲 channel 阻塞
func leak1() {
ch := make(chan int)
go func() {
ch <- 42 // 没有接收者,永久阻塞
}()
}
// 修复:使用带缓冲 channel 或确保有接收者
func fixed1() {
ch := make(chan int, 1)
go func() {
ch <- 42
}()
<-ch
}
// 场景 2:select 缺少退出条件
func leak2() {
ch := make(chan int)
go func() {
for {
select {
case v := <-ch:
fmt.Println(v)
// 缺少退出条件
}
}
}()
}
// 修复:添加 context 取消
func fixed2(ctx context.Context) {
ch := make(chan int)
go func() {
for {
select {
case v := <-ch:
fmt.Println(v)
case <-ctx.Done():
return
}
}
}()
}
// 场景 3:阻塞在 nil channel
func leak3() {
var ch chan int // nil channel
go func() {
<-ch // 永久阻塞
}()
}
// 修复:确保 channel 已初始化
func fixed3() {
ch := make(chan int)
close(ch)
go func() {
<-ch // 接收零值后退出
}()
}

8.3 使用 pprof 诊断泄漏#

import _ "net/http/pprof"
func main() {
go func() {
http.ListenAndServe("localhost:6060", nil)
}()
// 访问 http://localhost:6060/debug/pprof/goroutine?debug=1
// 查看 goroutine 堆栈
}

9. 常见并发陷阱#

9.1 闭包捕获循环变量#

// 错误:所有 goroutine 捕获同一个变量
func wrong() {
for i := 0; i < 3; i++ {
go func() {
fmt.Println(i) // 可能输出 3, 3, 3
}()
}
}
// 正确:创建局部变量副本
func right() {
for i := 0; i < 3; i++ {
i := i // 捕获当前值
go func() {
fmt.Println(i) // 输出 0, 1, 2
}()
}
}
// 正确:作为参数传递
func alsoRight() {
for i := 0; i < 3; i++ {
go func(n int) {
fmt.Println(n)
}(i)
}
}

9.2 向已关闭的 channel 发送#

// 错误:向已关闭 channel 发送会 panic
func wrong() {
ch := make(chan int)
close(ch)
ch <- 1 // panic: send on closed channel
}
// 正确:使用 defer-recover 或确保只有一个关闭者
func right() {
ch := make(chan int, 1)
// 使用 sync.Once 确保只关闭一次
var once sync.Once
once.Do(func() { close(ch) })
}

9.3 关闭 nil channel#

// 错误:关闭 nil channel 会 panic
func wrong() {
var ch chan int
close(ch) // panic: close of nil channel
}
// 正确:检查 channel 是否为 nil
func right(ch chan int) {
if ch != nil {
close(ch)
}
}

9.4 死锁:循环等待#

// 死锁场景
func deadlock() {
ch1 := make(chan int)
ch2 := make(chan int)
go func() {
<-ch1
ch2 <- 1
}()
<-ch2 // 等待 ch2,但 ch2 需要 ch1 先被发送
ch1 <- 1 // 永远无法到达
}
// 修复:使用缓冲 channel 或调整顺序
func fixed() {
ch1 := make(chan int, 1)
ch2 := make(chan int, 1)
go func() {
<-ch1
ch2 <- 1
}()
ch1 <- 1
<-ch2
}

9.5 竞态条件#

// 竞态条件
func race() {
var counter int
var wg sync.WaitGroup
for i := 0; i < 1000; i++ {
wg.Add(1)
go func() {
defer wg.Done()
counter++ // 竞态!
}()
}
wg.Wait()
fmt.Println(counter) // 结果不确定
}
// 使用互斥锁
func withMutex() {
var counter int
var mu sync.Mutex
var wg sync.WaitGroup
for i := 0; i < 1000; i++ {
wg.Add(1)
go func() {
defer wg.Done()
mu.Lock()
counter++
mu.Unlock()
}()
}
wg.Wait()
}
// 使用 atomic
func withAtomic() {
var counter int64
var wg sync.WaitGroup
for i := 0; i < 1000; i++ {
wg.Add(1)
go func() {
defer wg.Done()
atomic.AddInt64(&counter, 1)
}()
}
wg.Wait()
}
// 使用 channel
func withChannel() {
counter := make(chan int, 1)
counter <- 0 // 初始化
var wg sync.WaitGroup
for i := 0; i < 1000; i++ {
wg.Add(1)
go func() {
defer wg.Done()
c := <-counter
counter <- c + 1
}()
}
wg.Wait()
final := <-counter
fmt.Println(final)
}

10. 总结#

10.1 模式选择指南#

场景推荐模式
数据需要多阶段处理Pipeline
多任务并行执行Fan-out/Fan-in
限制并发数量Worker Pool
需要取消/超时控制Context
并发任务需要错误处理errgroup

10.2 并发安全检查清单#

  • 所有 goroutine 都有退出路径
  • 使用 context 实现取消传播
  • 避免向已关闭的 channel 发送
  • 共享资源使用互斥锁或 atomic
  • 使用 go run -race 检测竞态
  • 监控 goroutine 数量,防止泄漏
  • 正确处理并发任务的错误

10.3 工具推荐#

工具用途
go run -race竞态条件检测
net/http/pprofgoroutine 分析
golang.org/x/sync/errgroup并发错误处理
golang.org/x/tools/gopls静态分析

八、常见问题#

Q1:Pipeline 模式中如何处理错误?#

每个阶段遇到错误时通过专门的 error channel 传递,或使用 errgroup 管理所有 goroutine 的错误。推荐 errgroup,它会在任一 goroutine 出错时取消所有其他 goroutine。

Q2:Worker Pool 的 worker 数量怎么确定?#

取决于任务类型。CPU 密集型任务设为 runtime.NumCPU(),超过核心数只会增加调度开销;IO 密集型任务可以设为核心数的 2-10 倍,因为 IO 等待期间 goroutine 会让出线程。混合型任务从 CPU 核心数起步,通过基准测试逐步上调。详见第 4 节。

Q3:Fan-out 和 Worker Pool 有什么区别?#

Fan-out 是将同一输入分发给多个消费者并行处理(广播),Worker Pool 是多个 worker 从同一 channel 消费任务(竞争)。Fan-out 适合相同数据的不同处理,Worker Pool 适合不同数据的相同处理。

Q4:errgroup 和 WaitGroup 怎么选?#

errgroupWaitGroup 基础上增加了错误收集和 context 取消功能。如果需要知道哪个 goroutine 出错或需要取消,用 errgroup;如果只需等待完成,用 WaitGroup。

小结#

  • Pipeline 适合流式数据处理,但要注意 stage 间的背压控制。背压源于 Go channel 的发送阻塞机制:下游消费慢导致 channel 填满,上游发送被挂起。应对方式是控制并发源头(Worker Pool 限速,调节输入比依赖反馈更直接)、选择合理的 channel 缓冲大小(缓冲应吸收短期波动而非掩盖长期不匹配)、或对时效性数据主动丢弃
  • Fan-out/Fan-in 适合 CPU 密集型任务的并行分发,但 fan-out 数量应与 GOMAXPROCS 匹配,过多的 goroutine 只增加调度开销而不提升吞吐
  • Worker Pool 限制并发 goroutine 数量,避免资源耗尽。固定数量的 worker 让资源消耗有上限、调度压力可控,而不是按需创建导致内存和调度开销不可预测。核心参数是 worker 数:CPU 密集型等于核心数,IO 密集型为核心数的 2-10 倍,具体值靠基准测试确认
  • Context 用于取消和超时控制,是 Go 并发编程的基础设施,context.WithTimeout 应设在调用方而非被调用方,否则调用方无法控制超时
  • errgroup 采用”首个错误胜出”策略,g.Wait() 只返回第一个错误。这个设计在简洁性和实用性之间做了权衡:大多数并发场景只需要知道”出错了”和”第一个错误是什么”,全量错误收集是少数需求。配合 WithContext 使用时,任一 goroutine 出错会自动取消共享的 ctx,其他 goroutine 通过 ctx.Done() 提前退出。自动取消的设计假设是并发任务组是一个整体,一个失败则整体不可用,继续执行其他任务只是浪费资源。但如果任务之间完全独立,应该用 sync.WaitGroup 配合独立的错误收集,而不是 errgroup

参考资料#

支持与分享

如果这篇文章对你有帮助,欢迎支持作者或分享给更多人

Go 并发模式实战
https://blog.souloss.cn/posts/golang/go-concurrency/
作者
Souloss
发布于
2023-07-16
许可协议
CC BY-NC-SA 4.0

部分信息可能已经过时