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

相关推荐
BingoGo2 小时前
PHP clone 之后,为什么改副本会影响原对象?
后端·php
JaguarJack2 小时前
PHP clone 之后,为什么改副本会影响原对象?
后端·php·服务端
小灰灰搞电子3 小时前
Rust+Slint 实现动态消息提示框源码分享
开发语言·后端·rust
魏 无羡3 小时前
webclient
java·webclient
SkyWalking中文站3 小时前
BanyanDB 0.11.0:新特性与升级指南
数据库
小奏技术3 小时前
10 MB 的 Postman 替代品,启动不到 1 秒
后端
东风破_3 小时前
Text2SQL :用自然语言操作 SQLite 数据库
人工智能·后端
神仙别闹3 小时前
基于 C++ 实现两个有序链表序列的交集
java·c++·链表
swordbob4 小时前
ReentrantLock 与 AQS 完整学习手册
java·开发语言
₍˄·͈༝·͈˄*₎◞ ̑̑码4 小时前
MyBatis操作数据库
数据库·mybatis