Files

409 lines
11 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
tags: [go, goroutine, concurrency, pool]
create time: 2026-05-30 14:20
---
# Go 协程池
## 概述
梳理 Go 中协程池(Goroutine Pool)的设计动机与两种主流实现方案——基于 channel 的轻量级池子和 `golang.ccpool` 风格的成熟方案,分析其核心模式、线程安全保证以及适用边界。
## 正文
### 一、为什么需要协程池
> [!question] 思考:如果一个服务每秒收到 10000 个请求,每个请求启动一个 goroutine,会发生什么?
Go 的 goroutine 极其轻量——初始栈仅 2KB,创建成本约几个纳秒——所以 Go 社区流行"别管它,随时起一个"的信条。**但轻量不等于没有成本**。goroutine 仍然占用内存、参与 GC scan、需要调度器时间片。不加控制的 goroutine 爆炸在真实系统中屡见不鲜:
```go
// ❌ 灾难写法
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:
```go
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 结构示意
```mermaid
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 封装成一个易用的函数:
```go
// 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()
}
}
```
使用变得极其简洁:
```go
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` 虽然不是传统意义上的"池",但它解决了最常见的并发痛点:**有限度并发 + 错误传播 + 取消传递**。大多数情况下不需要自己写池子。
```go
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] 选哪个方案?
```mermaid
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 写法:
```go
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 使用的模板:
```go
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 |
下面是优雅停机的核心逻辑骨架:
```go
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 泄漏
```go
// ❌ worker 永远阻塞在有缓冲的 channel 上,如果没人读
jobs := make(chan int, 10) // 缓冲 10
jobs <- 1
// 如果 worker 没有启动,或者 worker 全部退出了但 channel 里还有未取的数据……
// goroutine 就泄漏了!
// ✅ 始终用 WaitGroup 或 context 跟踪所有 goroutine 的生命周期
```
#### 7.2 循环变量捕获(Go 1.22 之前)
```go
// ❌ 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 未关闭导致阻塞
```go
// ❌ 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 队列满了之后无限阻塞
```go
// ❌ submit 会一直阻塞直到有人消费
p.queue <- task
// ✅ 用 select + timeout/done channel 避免死锁
select {
case p.queue <- task:
return nil
case <-ctx.Done():
return ctx.Err()
default:
return ErrQueueFull
}
```
## 关联笔记
- [[Go Context]]