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 连接,数据像流水线一样依次流过各个阶段。
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 自动限速,生产者不会跑得比消费者更快。问题出在两个场景:
- 上游阻塞导致资源浪费:Stage 1 从数据库读取数据,Stage 3 写入远程 API 很慢。Stage 1 的 goroutine 阻塞在 channel 发送上,持有的数据库连接无法释放。
- 内存暴涨:如果用带缓冲 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
3.2 Fan-out 实现
// 并行执行多个 workerfunc 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% 时说明已经到上限。
以上都是经验起点,实际值必须通过基准测试确认。不同任务的 IO/CPU 比例差异很大,拍脑袋的数字往往不准。
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
// 危险:无限制创建 goroutinefunc 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 树形结构
当父 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() 作为根 Contextctx := 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 超时与取消的区别
超时和取消的触发方式不同,但底层机制相同:都是通过 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 的取消行为遵循一个明确的传播路径:
- 调用
g.Go(fn)启动多个 goroutine,它们共享同一个ctx - 任一 goroutine 返回非 nil 错误时,errgroup 内部调用
cancel()取消该 ctx - 其他正在运行的 goroutine 通过
ctx.Done()感知到取消信号,提前退出 g.Wait()等待所有 goroutine 结束后,返回第一个错误
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.WaitGroup | errgroup |
|---|---|---|
| 错误处理 | 需要手动实现 | 自动收集首个错误 |
| 取消机制 | 无 | 自动取消其他任务 |
| 使用复杂度 | 较高 | 较低 |
| 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 channelfunc 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 发送会 panicfunc 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 会 panicfunc wrong() { var ch chan int close(ch) // panic: close of nil channel}// 正确:检查 channel 是否为 nilfunc 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()}// 使用 atomicfunc 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()}// 使用 channelfunc 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/pprof | goroutine 分析 |
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 怎么选?
errgroup 在 WaitGroup 基础上增加了错误收集和 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 Channel 实现 — channel 底层源码
- Go WaitGroup 实现 — WaitGroup 源码
- Go Context 实现 — context 包源码
- Go errgroup 实现 — errgroup 官方文档
- Go 并发模式 — Go 官方 Pipeline 模式教程
- Go Memory Model — Go 内存模型
支持与分享
如果这篇文章对你有帮助,欢迎支持作者或分享给更多人
部分信息可能已经过时






