Files

11 KiB
Raw Permalink Blame History

tags, create time
tags create time
go
goroutine
concurrency
pool
2026-05-30 14:20

Go 协程池

概述

梳理 Go 中协程池(Goroutine Pool)的设计动机与两种主流实现方案——基于 channel 的轻量级池子和 golang.ccpool 风格的成熟方案,分析其核心模式、线程安全保证以及适用边界。

正文

一、为什么需要协程池

[!question] 思考:如果一个服务每秒收到 10000 个请求,每个请求启动一个 goroutine,会发生什么?

Go 的 goroutine 极其轻量——初始栈仅 2KB,创建成本约几个纳秒——所以 Go 社区流行"别管它,随时起一个"的信条。但轻量不等于没有成本。goroutine 仍然占用内存、参与 GC scan、需要调度器时间片。不加控制的 goroutine 爆炸在真实系统中屡见不鲜:

// ❌ 灾难写法
for _, item := range items {
    go process(item) // 如果 items 有 100 万个元素……
}
time.Sleep(time.Hour) // 等所有完成——但这也不是可靠的做法

当 goroutine 数量暴增时会导致:

  • 内存暴涨:即便每个只有 2KB,百万级就是 GB 级别
  • GC 压力:大量短命对象触发频繁 Full GC
  • CPU 抖动:太多 runnable goroutine 导致调度竞争
策略 控制粒度 实现复杂度 适用场景
直接并发 无 最低 小批量、短时间任务
Worker 池 goroutine 数量 中等 中等并发、需限流
Semaphore (channel) 最大并发数 低 快速限速

二、Channel + WaitGroup:最简协程池

这是最经典的实现方式——用一个固定数量的 worker 集合加一个任务 channel:

func Worker(id int, jobs <-chan int, results chan<- int, wg *sync.WaitGroup) {
    defer wg.Done()
    for j := range jobs { // range 自动退出——jobs channel 关闭后结束
        fmt.Printf("Worker %d started job %d\n", id, j)
        time.Sleep(time.Second)
        results <- j * 2
    }
}

func main() {
    const numWorkers = 3
    jobs := make(chan int, 100)       // 带缓冲的任务通道
    results := make(chan int, 100)     // 结果通道

    var wg sync.WaitGroup
    for w := 0; w < numWorkers; w++ {
        wg.Add(1)
        go Worker(w, jobs, results, &wg)
    }

    // 投递任务
    for j := 1; j <= 10; j++ {
        jobs <- j
    }
    close(jobs)  // 关键:关闭 channel,worker 的 range 才能退出

    go func() {
        wg.Wait()
        close(results)
    }()

    // 消费结果
    for r := range results {
        fmt.Println("result:", r)
    }
}

这个模式的精妙之处在于用 channel 的语义替代了锁:

  • range jobs 天然处理了"取任务 → 执行 → 取下一个"的循环
  • close(jobs) 作为停止信号,worker 不用轮询或检查 cancel
  • WaitGroup 确保所有 worker 完成后再关闭 results

[!tip] Channel 缓冲大小的选择

  • 太大:失去限速效果,退化成全并发
  • 太小:worker 频繁阻塞在取任务上,吞吐量下降
  • 经验值:等于或略大于 worker 数量(如 1-3 倍)即可,除非你确信生产者远快于消费者

2.1 结构示意

flowchart LR
    subgraph Producer["生产者"]
        P["task 1, 2, 3..."]
    end

    subgraph Pool["协程池"]
        J["jobs chan\n(buffered)"]
        W1["Worker 1"]
        W2["Worker 2"]
        W3["Worker 3"]
        J --> W1
        J --> W2
        J --> W3
    end

    R["results chan"]
    W1 --> R
    W2 --> R
    W3 --> R

    P --> J
    R --> Consumer["消费者"]

    style Pool fill:#e8f0fe,stroke:#1a73e8
    style J fill:#fff3e0,stroke:#f9a825

三、函数式选项:更友好的 API 封装

实际项目中通常会把上面的 boilerplate 封装成一个易用的函数:

// GoPool 是一个简单的协程池
type GoPool struct {
    workers int
    queue   chan func()
}

// NewGoPool 创建一个指定 worker 数量的协程池
func NewGoPool(workers int) *GoPool {
    return &GoPool{
        workers: workers,
        queue:   make(chan func(), 1000), // 队列缓冲
    }
}

// Start 启动 worker,返回 shutdown 函数
func (p *GoPool) Start(ctx context.Context) {
    var wg sync.WaitGroup
    for i := 0; i < p.workers; i++ {
        wg.Add(1)
        go func() {
            defer wg.Done()
            for {
                select {
                case fn, ok := <-p.queue:
                    if !ok {
                        return // channel 关闭,退出
                    }
                    fn()
                case <-ctx.Done(): // 支持外部取消
                    return
                }
            }
        }()
    }

    // 返回 shutdown 回调:关闭 channel + 等待 worker 退出
    p.shutdown = func() {
        close(p.queue)
        wg.Wait()
    }
}

使用变得极其简洁:

pool := NewGoPool(10)
pool.Start(context.Background())

// 提交异步任务
pool.Submit(func() {
    doSomething()
})

// 清理
pool.Shutdown() // close(channel) + WaitGroup.Wait()

[!note] Submit 之后如何拿到结果?

上面的版本是 fire-and-forget 模式(不返回值)。如果需要收集结果,让匿名函数写入一个共享 channel,或者用 sync.Once / errgroup.Group 做同步等待。这也是为什么下面要引入更成熟的工具。

四、errgroup:官方级别的简化方案

[!important] 先看这里

golang.org/x/sync/errgroup 虽然不是传统意义上的"池",但它解决了最常见的并发痛点:有限度并发 + 错误传播 + 取消传递。大多数情况下不需要自己写池子。

func downloadUrls(urls []string) error {
    g, ctx := errgroup.WithContext(context.Background())
    g.SetLimit(10) // ⚡ 限制最多 10 个并发 goroutine

    for _, url := range urls {
        u := url // 注意循环变量捕获陷阱
        g.Go(func() error {
            select {
            case <-ctx.Done(): // 任意一个失败,全部取消
                return ctx.Err()
            default:
                return fetch(u)
            }
        })
    }

    return g.Wait() // 等全部完成,或第一个错误返回
}

errgroup 的核心价值:

  • SetLimit(10):一行代码搞定并发限制,本质上是 channel semaphore
  • Go(fn) 返回 error:任何一个 goroutine panic 或返回非 nil error,整个组取消并提前退出
  • WithCancel + WaitGroup 一体化:不用分别管理

[!summary] 选哪个方案?

flowchart TD
    A["需要限制并发数?"] -->|否| B["直接 go + 不管"]
    A -->|是| C["errgroup.SetLimit()"]
    C --> D["还需要自定义任务类型\n(结构化数据流)?"]
    D -->|否| E["✅ errgroup 够用了"]
    D -->|是| F["自定义协程池\nchannel + Worker"]
    F --> G["还需要优雅停机?\n(关闭后拒绝新任务)?"]
    G -->|是| H["golang.ccpool /\ntpool 等第三方库"]
    G -->|否| E

五、Semaphore:另一种限速思路

除了 errgroup 内部用的 buffered channel + counter 方案,还有一种更直观的 semaphore 写法:

type semaphore struct {
    ch chan struct{}
}

func newSemaphore(n int) semaphore {
    return semaphore{ch: make(chan struct{}, n)}
}

func (s semaphore) acquire()  { s.ch <- struct{}{} }
func (s semaphore) release()  { <-s.ch }

配合 goroutine 使用的模板:

sem := newSemaphore(10)

for _, item := range items {
    go func(i Item) {
        sem.acquire()
        defer sem.release()
        process(i)
    }(item)
}

这和 errgroup 的 SetLimit 底层原理相同,只是手动暴露了 acquire/release。适合需要将 semaphores 传给多个独立函数的场景。

六、生产级协程池的特性要求

如果要在真实产品中落地自己的池子,通常需要考虑以下能力:

特性 说明 实现要点
优雅停机 Stop 后不接收新任务,等待已提交的完成 state machine (running/stopping/stopped),Select 判断状态
动态扩缩容 根据负载调整 worker 数量 monitor 定时检查队列长度
任务超时 单个任务执行超时自动跳过 select + time.After
优先级队列 高优先级任务先执行 多 channel 或 heap 结构
监控指标 活跃 worker、排队数、丢弃数 atomic counters + prometheus registry

下面是优雅停机的核心逻辑骨架:

type State int

const (
    StateRunning State = iota
    StateStopping
    StateStopped
)

type SafePool struct {
    state   State
    mu      sync.RWMutex
    queue   chan Task
    workers int
    done    chan struct{}
}

func (p *SafePool) Submit(t Task) error {
    p.mu.RLock()
    defer p.mu.RUnlock()

    if p.state == StateStopped {
        return ErrPoolStopped
    }

    select {
    case p.queue <- t:
        return nil
    default:
        return ErrQueueFull // 队列满时非阻塞拒绝
    }
}

func (p *SafePool) Stop() {
    p.mu.Lock()
    defer p.mu.Unlock()

    if p.state == StateStopped {
        return
    }
    p.state = StateStopping
    close(p.done)   // 通知 worker 退出
    p.state = StateStopped
}

七、常见陷阱清单

7.1 goroutine 泄漏

// ❌ worker 永远阻塞在有缓冲的 channel 上,如果没人读
jobs := make(chan int, 10) // 缓冲 10
jobs <- 1
// 如果 worker 没有启动,或者 worker 全部退出了但 channel 里还有未取的数据……
// goroutine 就泄漏了!

// ✅ 始终用 WaitGroup 或 context 跟踪所有 goroutine 的生命周期

7.2 循环变量捕获(Go 1.22 之前)

// ❌ Go 1.22 之前的版本,所有 goroutine 共享同一个 i
for i := 0; i < 5; i++ {
    go func() {
        fmt.Println(i) // 可能全部输出 5!
    }()
}

// ✅ 显式传参(推荐)或就地拷贝
for i := 0; i < 5; i++ {
    go func(val int) {
        fmt.Println(val)
    }(i)
}

[!note] Go 1.22 变更

从 Go 1.22 开始,循环变量的行为被修改为每次迭代创建新的副本。如果你的项目仍跑在更早的版本上,上述问题依然是真实的 bug。

7.3 channel 未关闭导致阻塞

// ❌ main 函数在 wg.Wait() 之前就返回了——results 永远不会被关闭
var wg sync.WaitGroup
for w := 0; w < 3; w++ {
    wg.Add(1)
    go func() {
        defer wg.Done()
        for j := range jobs {
            results <- j * 2
        }
    }()
}
wg.Wait()
// 此处缺少 close(results)

// ✅ 独立的 goroutine 负责在 wg.Wait() 后关闭 results
go func() {
    wg.Wait()
    close(results)
}()

7.4 队列满了之后无限阻塞

// ❌ submit 会一直阻塞直到有人消费
p.queue <- task

// ✅ 用 select + timeout/done channel 避免死锁
select {
case p.queue <- task:
    return nil
case <-ctx.Done():
    return ctx.Err()
default:
    return ErrQueueFull
}

关联笔记