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 并发的正确打开方式。

相关推荐
kirs_ur6 小时前
ECC & LDPC — SSD 的数据卫士
服务器·数据库·性能优化
是三一seven6 小时前
Sql注入基础
数据库·安全·网络安全
小Ti客栈7 小时前
Spring Boot 集成 Springdoc-OpenAPI 与 Knife4j实现接口文档与可视化调试
java·spring boot·后端
Sirens.7 小时前
MySQL表设计进阶-约束范式连接索引与事务
android·数据库·mysql
Wang's Blog7 小时前
Go-Zero 项目开发22:用户群聊功能的实现与完善
开发语言·golang
Ai拆代码的曹操7 小时前
Spring 事务 REQUIRES_NEW 嵌套调用:连接池翻倍的秘密
java·后端·spring
动恰客流统计8 小时前
ReID边缘计算视觉统计:餐饮店客流增长的数字化破局路径
java·大数据·运维·人工智能
字节跳动开源8 小时前
火山引擎开源 Agent 驱动的搜索自迭代技术
数据库·开源·agent
Ivanqhz8 小时前
Rust &‘static str浅析
java·前端·javascript·rust
IT_陈寒9 小时前
SpringBoot这个分页坑,我踩了三天才爬出来
前端·人工智能·后端