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

相关推荐
人道领域1 小时前
【LeetCode刷题日记】贪心算法理论与实战:455.分发饼干最优解
java·开发语言·数据结构·算法·leetcode·贪心算法
_abab1 小时前
Java面试宝典:从基础到架构2
java·面试·架构
万亿少女的梦1682 小时前
基于Spring Boot与Vue的繁星技术论坛系统设计与实现
java·spring boot·mysql·vue·系统设计
阿pin2 小时前
Android随笔-神经系统Handler
android·java·开发语言·handler
源图客2 小时前
Claude Code基础使用
服务器·前端·数据库
布朗克1682 小时前
Go 入门到精通-09-复合类型之Map
开发语言·后端·golang·map
ch.ju2 小时前
Java程序设计(第3版)第四章——final修饰的方法和类
java·开发语言
摇滚侠2 小时前
云原生 Java 架构师的第一课 K8s+Docker+KubeSphere+DevOps 26-40
java·云原生·kubernetes