上周帮隔壁组排查了一个线上事故:一个突发的批量推送任务,直接让微服务节点的 goroutine 数量飙升到了 30w+,随后系统频繁触发 GC STW,最终被 K8s 的 OOMKilled 无情干掉。
在 Go 里启动一个协程只要一个 go 关键字,非常廉价(初始只要 2KB~4KB 栈内存),但“廉价”不等于“免费”。一旦无节制并发,系统很快就会被调度延迟和 GC 拖垮。
今天聊聊我们在生产环境里怎么搞一个轻量级、靠谱的 Worker Pool,重点解决协程泄漏、优雅关闭和背压(Backpressure)。
一、直接 go func() 的隐患
生产业务里无脑开 goroutine 的坑主要有三个:
- 瞬时内存打满:几十万个 goroutine 堆积,光栈内存就有几百兆,加上引用的业务对象,GC 扫描直接起飞。
- 缺乏生命周期管控(泄漏隐患):父 Context 已经超时取消了,后台的子 goroutine 还在死磕网络 IO,或者阻塞在一个没人消费的 Channel 上,永远死不掉。
- 未捕获的 Panic:子协程内部一旦发生 panic 且没有 recover,整个进程直接挂掉。
二、极简防泄漏 Worker Pool 实现
在业务场景下,我们并不总是需要引入重型的开源协程池库。一个支持 Context 级联取消、优雅关闭和 panic 防护的 Pool 代码其实百行内就能拿下。
go
package pool
import (
"context"
"fmt"
"log"
"sync"
)
type Task func(ctx context.Context)
type WorkerPool struct {
workerNum int
taskQueue chan Task
wg sync.WaitGroup
ctx context.Context
cancel context.CancelFunc
}
func NewWorkerPool(workerNum, queueSize int) *WorkerPool {
ctx, cancel := context.WithCancel(context.Background())
return &WorkerPool{
workerNum: workerNum,
taskQueue: make(chan Task, queueSize),
ctx: ctx,
cancel: cancel,
}
}
func (p *WorkerPool) Start() {
for i := 0; i < p.workerNum; i++ {
p.wg.Add(1)
go p.worker()
}
}
func (p *WorkerPool) worker() {
defer p.wg.Done()
for {
select {
case <-p.ctx.Done():
// 收到全局停止信号,退出
return
case task, ok := <-p.taskQueue:
if !ok {
// 任务队列已关闭
return
}
p.runSafe(task)
}
}
}
// 防泄漏核心 1: 兜底 panic,避免单一任务炸掉整个 worker 或进程
func (p *WorkerPool) runSafe(task Task) {
defer func() {
if r := recover(); r != nil {
log.Printf("[WorkerPool] task panic recovered: %v", r)
}
}()
task(p.ctx)
}
// 提交任务: 包含防死锁/背压机制
func (p *WorkerPool) Submit(ctx context.Context, task Task) error {
select {
case <-p.ctx.Done():
return fmt.Errorf("worker pool is stopped")
case <-ctx.Done():
return ctx.Err() // 提交方超时放弃,不堵死调用方
case p.taskQueue <- task:
return nil
}
}
// 防泄漏核心 2: 优雅退出,保证已提交的任务消费完,且不漏协程
func (p *WorkerPool) Stop() {
close(p.taskQueue) // 停止接收新任务,worker 会消费完剩余队列
p.wg.Wait() // 等待所有 worker 退出
p.cancel() // 兜底释放资源
}
三、Benchmark 对比:无脑并发 vs 池化
我写了个简单的基准测试,模拟并发执行 100,000 个简单的计算+模拟耗时任务。
环境:Apple M2 Pro / 16GB / Go 1.22
text
goos: darwin
goarch: arm64
pkg: bench_test
cpu: Apple M2 Pro
BenchmarkDirectGoroutine-10 100000 11842 ns/op 2840 B/op 19 allocs/op
BenchmarkWorkerPool-10 100000 3951 ns/op 320 B/op 3 allocs/op
数据非常直观:
- 耗时:Worker Pool 的耗时约是无脑
go func()的 33%。省去了大量协程创建、调度和销毁的开销。 - 内存分配(B/op):直接从 2840 B/op 骤降至 320 B/op。对于高 QPS 接口,这意味着极大地减轻了 GC 标记扫描的压力。
四、防泄漏实战踩坑
写并发代码,最怕的就是泄漏而自知。在生产落地上有几个硬规则:
- 绝对禁止无限制的非缓冲 Channel 投递
提交任务时,千万别写p.taskQueue <- task裸写。如果队列满了,调用方协程会无限期阻塞在发送端。必须配合select监听上游的ctx.Done(),超时快速失败。 - Goroutine Leak 检测
单元测试里一定要加上泄漏检测工具。推荐go.uber.org/goleak:go只要测试结束时还有协程残留挂起,测试直接红灯。func TestWorkerPool(t *testing.T) { defer goleak.VerifyNone(t) // 你的业务测试代码 } - 线上观察工具:pprof
别猜协程有没有泄露,打开/debug/pprof/goroutine?debug=1看一眼: 如果看到成千上万个协程停在runtime.gopark、chanrecv或者某个net/http请求等待处,那 100% 是上下文传递链路断了。
总结
- 任务数量未知/突发量大:必须加 Worker Pool 限制最大并发度,保护下游依赖(如数据库/第三方服务)。
- 任务极其轻量且频次不高:标准库原生的
go func()配合sync.WaitGroup足够,没必要过度设计。 - 造轮子还是用开源?:如果是超大吞吐(百万级)且极致追求零内存分配,可以直接上开源的
panjf2000/ants;如果是内部简单异步削峰,上面这几十行自制代码反而最稳、最可控。
#Golang
#后端
#工具
许可协议:CC BY-NC 4.0
更新于 1 小时前
觉得文章有帮助?点个赞吧!
0 条评论


