CClara轻量论坛系统

Go 语言并发模式:Worker Pool / Fan-in / Fan-out

dp32323
dp32323 正式会员正式会员
发布于 2026-11-11 19:12 ·3 浏览 ·2 回复
内容摘要

介绍 Go 语言中 Worker Pool、Fan-out、Fan-in 三种并发模式各自的作用与相互关系——Fan-out 负责把任务分发给多个处理者,Fan-in 负责把多个 channel 的输出汇总到一个 channel,Worker Pool 则是两者结合后的完整形态;同时给出可反复套用、能直接复制运行的代码骨架,用于限制并发数并安全地批量处理任务。

学完这篇你能得到三个可以反复套用的并发骨架——Worker Pool、Fan-out、Fan-in 的标准写法,以及它们组合起来处理批量任务时的完整代码,直接复制就能跑。

第一步:先分清三个模式各管什么

  • Worker Pool(工作池):固定 N 个 goroutine 从同一个任务 channel 取活干。作用是限制并发数,避免一万个任务开一万个 goroutine 打爆内存或下游服务。
  • Fan-out(扇出):把同一批任务分发给多个处理者,本质就是 Worker Pool 的「启动多个 goroutine 消费同一个 channel」这个动作。
  • Fan-in(扇入):把多个 channel 的输出合并到一个 channel。作用是统一汇总结果,让调用方只 range 一个 channel。

三者关系:Fan-out 是分发,Fan-in 是回收,Worker Pool 是这两件事加起来的完整形态。

先建目录跑通环境:

mkdir goconc && cd goconc
go mod init goconc

第二步:写一个最小 Worker Pool

新建 main.go,核心是「生产者关 channel,消费者 range channel」:

package main

import (
	"fmt"
	"sync"
)

func worker(id int, jobs <-chan int, results chan<- int, wg *sync.WaitGroup) {
	defer wg.Done()
	for j := range jobs {
		results <- j * j
	}
}

func main() {
	const numJobs, numWorkers = 10, 4
	jobs := make(chan int, numJobs)
	results := make(chan int, numJobs)

	var wg sync.WaitGroup
	for w := 1; w <= numWorkers; w++ {
		wg.Add(1)
		go worker(w, jobs, results, &wg)
	}

	go func() {
		wg.Wait()
		close(results) // 所有 worker 退出后才关结果 channel
	}()

	for i := 1; i <= numJobs; i++ {
		jobs <- i
	}
	close(jobs) // 任务发完,通知 worker 退出 range

	for r := range results {
		fmt.Println(r)
	}
}

跑起来:go run main.go。

这里有两个关键约定:jobs 由生产者关闭(谁发谁关),results 由 WaitGroup 的等待方关闭。channel 是方向性资源,关错地方就是 panic。

注意:jobs 和 results 都用了带缓冲的 channel。如果两个都是无缓冲,主 goroutine 在 jobs <- i 时就会阻塞,而 worker 想往 results 写又没人读,直接死锁。缓冲大小至少要等于任务数,或者像下面那样把发送放进独立 goroutine。

第三步:Fan-out —— 多个消费者抢同一个 channel

上面 for w := 1; w <= numWorkers; w++ 这个循环就是 Fan-out。想动态控制并发数,用带缓冲 channel 当信号量更灵活:

sem := make(chan struct{}, 10) // 最多 10 个并发

for _, task := range tasks {
	sem <- struct{}{} // 占坑
	go func(t Task) {
		defer func() { <-sem }() // 释放
		process(t)
	}(task)
}
for i := 0; i < cap(sem); i++ {
	sem <- struct{}{} // 等所有 goroutine 跑完
}

注意:Go 1.22 之前,for _, task := range 里的循环变量是被所有 goroutine 共享的,必须像上面那样通过参数 t Task 传进去。Go 1.22 起每个迭代独立变量,但显式传参永远是安全写法,建议保留。这个坑用 go vet 也不一定报,靠 go run -race 能抓出来。

第四步:Fan-in —— 多个 channel 合成一个

典型场景:多个数据源并发拉取,结果要统一处理。

func fanIn(ctx context.Context, chs ...<-chan int) <-chan int {
	out := make(chan int)
	var wg sync.WaitGroup

	for _, ch := range chs {
		wg.Add(1)
		go func(c <-chan int) {
			defer wg.Done()
			for {
				select {
				case v, ok := <-c:
					if !ok {
						return
					}
					select {
					case out <- v:
					case <-ctx.Done():
						return
					}
				case <-ctx.Done():
					return
				}
			}
		}(ch)
	}

	go func() {
		wg.Wait()
		close(out) // 唯一关闭 out 的地方
	}()
	return out
}

调用方统一 for v := range fanIn(ctx, a, b, c) 即可,不用关心源有几个。内层再套一层 select 是为了防止 ctx 取消后 out 没人读、转发 goroutine 卡死在发送上——这就是最容易被忽略的 goroutine 泄漏点。

注意:Fan-in 里 out 只能在一处 close。如果每个转发 goroutine 都 defer close(out),第一个跑完就 panic: send on closed channel。正确做法永远是用 WaitGroup 等到全部退出再关。

第五步:组合成真实可用的批量处理器

实际业务里还需要错误收集和取消。加一个 error channel 和一个 context.WithCancel:

func Run(ctx context.Context, items []int, workers int) ([]int, []error) {
	jobs := make(chan int)
	resCh := make(chan int)
	errCh := make(chan error, len(items))

	var wg sync.WaitGroup
	for i := 0; i < workers; i++ {
		wg.Add(1)
		go func() {
			defer wg.Done()
			for it := range jobs {
				if it < 0 {
					errCh <- fmt.Errorf("bad item: %d", it)
					continue
				}
				select {
				case resCh <- it * 2:
				case <-ctx.Done():
					return
				}
			}
		}()
	}

	go func() {
		defer close(jobs)
		for _, it := range items {
			select {
			case jobs <- it:
			case <-ctx.Done():
				return
			}
		}
	}()

	go func() {
		wg.Wait()
		close(resCh)
		close(errCh)
	}()

	var out []int
	for r := range resCh {
		out = append(out, r)
	}
	var errs []error
	for e := range errCh {
		errs = append(errs, e)
	}
	return out, errs
}

关键点:errCh 用带缓冲 len(items),保证 worker 写错误永不阻塞;jobs 无缓冲但由独立 goroutine 发送,主 goroutine 不会卡住。

验证一下有没有并发问题:

go run -race main.go
go vet ./...

小结

  • Worker Pool = 固定消费者 + 任务 channel,核心价值是限流而不是提速。
  • Fan-out 是分发动作,用 channel 信号量或固定循环都能实现;Fan-in 是汇总动作,靠 WaitGroup + 单一 close 收口。
  • channel 归属要清晰:发送方关任务 channel,等待方关结果 channel,一个 channel 只关一次。
  • 想不死锁:任务和结果 channel 都加缓冲,或把发送动作放进独立 goroutine。
  • 想不泄漏:所有阻塞的发送和接收都要套 select { case <-ctx.Done(): return }。
  • 上生产前必跑 go run -race,循环变量捕获和并发写切片这两类 bug 只有它能抓。
本文转载自 Clara轻量论坛系统,原文地址:https://www.leleweb.cn/thread-1145.html
转载请注明出处,版权归原作者所有。
—/ 5暂无评分
还没有评分,来打第一分吧

全部回复 2

itjianghu
itjianghu 正式会员正式会员认证极客认证极客 1楼 2026-11-11 19:21

骨架是标准写法,可以直接参考;但要真正用到批量任务上,还差三块:错误回传、context 取消、结果顺序。

错误回传:现在 worker 只回传 j*j,一个任务失败整批就没法区分是哪个出问题。建议 results 传结构体而不是裸 int:

type Result struct{ Idx, Val int; Err error }

取消:批量任务常见「下游挂了立刻停」的需求,worker 循环里加个 select 就够:

func worker(ctx context.Context, jobs <-chan int, out chan<- Result, wg *sync.WaitGroup) {
	defer wg.Done()
	for j := range jobs {
		select {
		case <-ctx.Done():
			return
		default:
		}
		out <- Result{Idx: j, Val: j * j}
	}
}

顺序:fan-in 合并后结果天然乱序,真要用在「按下标写回结果切片」的场景,必须带 Idx 在收集端归位,否则对不上。

另外顺带说一句:帖里提到 jobs/results 缓冲「至少要等于任务数」——其实把 jobs <- i 那段放进独立 goroutine,无缓冲也能跑通,带缓冲主要是控背压(避免主协程一口气把内存塞爆)。两种都能用,看你是想限流还是想简单。

最后,如果不想手工管 close 和 wg 这堆账,golang.org/x/sync/errgroup 值得一试:g.Go() 返回第一个 error 时会自动 cancel 整个 ctx,省掉一大半样板。

一只肉包
一只肉包 正式会员正式会员认证极客认证极客 #2398 2楼 2026-11-11 19:30
itjianghu:骨架是标准写法,可以直接参考;但要真正用到批量任务上,还差三块:错误回传、context 取消、结果顺序。 **错误回传**:现在 worker 只回传 `j…

你补的三块都在点上,尤其结果顺序——fan-in 出乱序是必然的,按下标回填是最省事的解法,比加锁 map 干净得多。不过你贴的取消写法有两个遗漏,我踩过,补一下。

一、for j := range jobs 会卡死取消失效。 没有任务且 jobs 还没 close 时,goroutine 阻塞在 channel 上,ctx.Done() 的 select 根本轮不到执行。得把取任务和判取消合成一个 select:

for {
    select {
    case <-ctx.Done():
        return
    case j, ok := <-jobs:
        if !ok { return }
        // 处理 j
    }
}

二、写 out 那一步也要防泄漏。 调用方一旦因 error 提前 return、不再读 out,worker 的 out <- Result{...} 会永久阻塞,导致 wg.Wait() 永不返回、goroutine 全泄漏——这比死锁更难查,pprof 看半天。要么给 out 缓冲 ≥ 任务数,要么写的时候也套 select:

select {
case out <- res:
case <-ctx.Done():
    return
}

三、errgroup 省的是 wg + 错误收集 + cancel 这三本账,但你得知道它的默认行为:不限并发,要 Worker Pool 语义得显式 g.SetLimit(n)(x/sync 0.3.0+)。结果收集还是得自己做——预分配 results := make([]T, len(jobs)) 然后 results[i] = v,每个下标只写一次,天然无竞争、不用加锁。另外它不 recover panic,goroutine 里裸 panic 照样整个进程挂,关键路径该加的 recover 还得加。

最后一句:如果你要的是「一个任务失败也把已完成的都收回来再统一决策」,errgroup 的自动 cancel 语义就不够用了——拆成两个 ctx,一个只管「别再派发新任务」,正在飞的让它跑完,收集端收全了再判断。