Go 并发实战:用 channel 实现 worker pool

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 由三部分组成:

  • 一个 jobs channel:生产者往里塞任务,worker 从里面取。
  • 固定数量的 worker goroutine:每个都在 for job := range jobs 里循环消费。
  • 一个 results channel(可选):收集每个任务的结果。

关键在于: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 个。

三、三个必须理解的关闭时序

上面代码里最容易写错的就是"谁关谁、什么时候关"。记住三条:

  1. jobs 由生产者关闭 :任务投递完由投递方 close(jobs)。worker 端用 for range jobs,channel 关闭且缓冲取空后会自动结束循环------这是 worker 优雅退出的关键,不用额外的退出信号。
  2. results 由"所有 worker 都结束后"关闭 :所以要用 sync.WaitGroup 等全部 worker Done(),再 close(results)。谁往 channel 写,谁负责关。
  3. 绝不能在 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 等所有 worker Done() 后关,别在 worker 里关。
  • wg.Wait() 放独立 goroutine,避免主流程死锁。
  • context 做取消和超时,用 Result 带 error 收集失败,别让单个任务崩掉整批。

一句话记忆:goroutine 不是越多越好,用 channel + 固定 worker 把并发数握在自己手里,才是 Go 并发的正确打开方式。

相关推荐
m0_640602444 分钟前
2026 年餐饮收银系统前后端技术实现——核心架构与原理详解
后端·微服务·云原生·架构
长谷深风1116 分钟前
Agent 何时该 Replan:五个关键判断
java·大数据·开发语言·ai agent·ai智能体·agent设计·clarify机制
Scene2167 分钟前
Flux 与 Mono:Project Reactor 核心响应式类型深度解析
后端
lingran__12 分钟前
C++ STL unordered系列(哈希) 底层剖析与模拟实现万字详解 | 基于哈希表,复刻 SGI-STL 泛型哈希容器架构
开发语言·c++·后端·哈希算法·哈希表·泛型编程·unordered系列
Hugh-Yu-13012314 分钟前
zhangxuefeng-skill配置教程
数据库
布莱克60516 分钟前
理解B+树:原理、特性与应用场景
数据结构·数据库·mysql
冰暮流星19 分钟前
mysql之排序查询
数据库·mysql
71777722 分钟前
让权限治理成为可复制资产:Gitee Team 空间配置方案与安全级别落地详解
数据库·gitee
小林ixn23 分钟前
NestJS 入门实战:从 0 到 1 撸一个 Todo CRUD,感受装饰器与模块化的优雅
后端·mvc·nestjs