Go 并发实战:用 channel 实现 worker pool
处理一批任务时,很多人第一反应是给每个任务开一个 goroutine:for _, job := range jobs { go handle(job) }。任务少没事,但如果 jobs 有几万个、每个任务还要连数据库或调外部接口,瞬间几万个 goroutine 一拥而上,连接池被打爆、下游被限流、内存飙升。正确的做法是固定数量的 worker 从一个队列里取任务,也就是 worker pool。这篇用一个可运行的例子把它讲透,并说清楚几个新手必踩的坑。
一、朴素写法的问题
先看这段"能跑但危险"的代码:
go
func main() {
jobs := makeJobs(10000)
for _, j := range jobs {
go process(j) // 一次性拉起 1 万个 goroutine
}
time.Sleep(5 * time.Second) // 还得靠 sleep 等它们跑完
}
两个问题:并发数完全不可控;用 time.Sleep 等待既不可靠又浪费。我们要的是"最多同时 N 个任务在跑,且能准确知道全部完成"。
二、worker pool 的核心结构
worker pool 由三部分组成:
- 一个
jobschannel:生产者往里塞任务,worker 从里面取。 - 固定数量的 worker goroutine:每个都在
for job := range jobs里循环消费。 - 一个
resultschannel(可选):收集每个任务的结果。
关键在于:channel 天然是并发安全的队列,多个 worker 从同一个 channel 读,Go runtime 保证一个任务只会被一个 worker 拿到。
go
package main
import (
"fmt"
"sync"
)
type Result struct {
Job int
Val int
}
func worker(id int, jobs <-chan int, results chan<- Result, wg *sync.WaitGroup) {
defer wg.Done()
for j := range jobs { // channel 关闭且取空后,range 自动退出
results <- Result{Job: j, Val: j * j}
}
}
func main() {
const numWorkers = 5
jobs := make(chan int, 100)
results := make(chan Result, 100)
var wg sync.WaitGroup
for i := 0; i < numWorkers; i++ {
wg.Add(1)
go worker(i, jobs, results, &wg)
}
// 投递任务,投完立刻关闭 jobs,通知 worker 没有新活了
go func() {
for j := 1; j <= 20; j++ {
jobs <- j
}
close(jobs)
}()
// 等所有 worker 退出后关闭 results,主 goroutine 才能安全 range
go func() {
wg.Wait()
close(results)
}()
for r := range results {
fmt.Printf("job %d -> %d\n", r.Job, r.Val)
}
}
这里只有 5 个 worker,无论投多少任务,同时在跑的最多就是 5 个。
三、三个必须理解的关闭时序
上面代码里最容易写错的就是"谁关谁、什么时候关"。记住三条:
jobs由生产者关闭 :任务投递完由投递方close(jobs)。worker 端用for range jobs,channel 关闭且缓冲取空后会自动结束循环------这是 worker 优雅退出的关键,不用额外的退出信号。results由"所有 worker 都结束后"关闭 :所以要用sync.WaitGroup等全部 workerDone(),再close(results)。谁往 channel 写,谁负责关。- 绝不能在 worker 里关
results:多个 worker 会重复 close,直接 panic(close of closed channel)。
wg.Wait() 必须放在单独的 goroutine 里,否则主 goroutine 卡在 wg.Wait() 上,而 results 没人消费,worker 又写不进去,直接死锁。
四、给 worker 加上错误处理与取消
真实任务会失败,也可能需要中途取消(比如超时或用户中断)。用 context 传递取消信号,用 Result 里带 error:
go
type Result struct {
Job int
Val int
Err error
}
func worker(ctx context.Context, jobs <-chan int, results chan<- Result, wg *sync.WaitGroup) {
defer wg.Done()
for {
select {
case <-ctx.Done(): // 收到取消,立即退出,不再取新任务
return
case j, ok := <-jobs:
if !ok {
return // jobs 已关闭且取空
}
val, err := doWork(ctx, j)
results <- Result{Job: j, Val: val, Err: err}
}
}
}
func doWork(ctx context.Context, j int) (int, error) {
if j%7 == 0 {
return 0, fmt.Errorf("job %d 触发了错误", j)
}
return j * j, nil
}
调用方用 ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second),超时后 worker 会从 <-ctx.Done() 分支退出,不会把剩余任务跑完。
五、常见坑:goroutine 泄漏
如果生产者忘了 close(jobs),worker 的 for range jobs 永远阻塞,这些 goroutine 就泄漏了------程序不退出、内存不释放。排查手段:跑测试时打印 runtime.NumGoroutine(),如果任务跑完后 goroutine 数不回落到基线,基本就是有 channel 没关或没被消费。
另一个坑是 results channel 无缓冲且没人及时读:worker 写 results <- r 会阻塞,进而 jobs 也消费不动,整体卡死。给 channel 加合理缓冲,或保证消费方一直在 range,能避免这类背压死锁。
六、小结
- worker pool = 固定数量 worker + 一个 jobs channel + WaitGroup,把并发数牢牢卡在 N。
for range channel是 worker 优雅退出的标准姿势,靠生产者close(jobs)触发。- 谁写谁关:
jobs生产者关,results等所有 workerDone()后关,别在 worker 里关。 wg.Wait()放独立 goroutine,避免主流程死锁。- 用
context做取消和超时,用 Result 带 error 收集失败,别让单个任务崩掉整批。
一句话记忆:goroutine 不是越多越好,用 channel + 固定 worker 把并发数握在自己手里,才是 Go 并发的正确打开方式。