先确认现象:是并发量过高还是资源争抢?
遇到 goroutine 数量不受控时,我一般先看 runtime.NumGoroutine() 当前值,同时观察 CPU、内存和文件描述符的磨损。如果 goroutine 数持续上涨但 CPU 空闲,大概率是任务堆积在阻塞操作上,而不是真的并发高。这时候直接套用并发限制反而可能掩盖真正瓶颈——比如外网请求超时或数据库连接池打满。
通过固定数量的 worker goroutine 从任务 channel 中读取任务,主协程向 channel 发送任务。使用 sync.WaitGroup 等待所有 worker 退出。操作步骤:定义 worker 函数,用 for-range 循环读取 channel,任务完成后调用 wg.Done。判断条件:任务 channel 已满时,发送方阻塞。检查方法可用 runtime.NumGoroutine() 观察实际并发数。风险在于关闭任务 channel 后,若 worker 仍在等待,会导致 goroutine 泄漏。建议在关闭 channel 前通知 worker 退出,或使用 select 配合退出 channel。
容易误判的地方:循环内直接 go func
很多新手在 for 循环里直接 go func(),不检查 channel 是否就绪,导致瞬间拉起上千个 goroutine。这种写法在本地测试看不出问题,一旦 QPS 上来,内存暴涨,FD 耗尽,服务直接挂掉。我排查时第一眼就看有没有 select 或 channel 做背压。没有的话,十有八九是这里出的问题。
建议的处理顺序:从简单到可靠
我通常会先从令牌桶方案试起,因为它改动最小,且不需要额外结构。常用手段是创建一个容量为 N 的缓冲 channel,每个 goroutine 启动前向 channel 发送一个空结构体,执行完毕后从 channel 接收一个值以释放令牌。当 channel 已满时,发送操作会阻塞,从而限制并发数。判断条件可观察 len(channel) 是否等于容量;若长时间阻塞说明已达上限。常见坑是忘记在 goroutine 结束时接收令牌,导致 channel 永远被占满,引发死锁。建议使用 defer 确保释放,例如:defer <- semaphore。注意,写 defer 时要把接收操作放在函数开头,避免中间 return 漏掉。
如果觉得令牌桶不够灵活,可以换成 Worker Pool 模式。通过固定数量的 worker goroutine 从任务 channel 中读取任务,主协程向 channel 发送任务。使用 sync.WaitGroup 等待所有 worker 退出。操作步骤:定义 worker 函数,用 for-range 循环读取 channel,任务完成后调用 wg.Done。判断条件:任务 channel 已满时,发送方阻塞。检查方法可用 runtime.NumGoroutine() 观察实际并发数。风险在于关闭任务 channel 后,若 worker 仍在等待,会导致 goroutine 泄漏。建议在关闭 channel 前通知 worker 退出,或使用 select 配合退出 channel。这个模式很适合消息队列消费场景,worker 数量等于数据库连接池或外部 API 并发限制。
如果项目中已经引入了 ants 协程池,直接用它更省心。第三方库 go.uber.org/ants 提供了协程池实现。使用 ants.NewPool(N) 创建指定大小的池子,通过 pool.Submit 提交任务。当池子满时,Submit 默认阻塞等待空闲 worker;也可配置为非阻塞模式,返回错误。检查方法:pool.Running() 返回当前执行任务数,pool.Free() 返回空闲数。需注意调用 pool.Release 释放资源,否则 goroutine 无法回收。性能敏感场景下,建议复用池对象,避免频繁创建销毁。我用 ants 时习惯给它配一个 context 超时,防止 Submit 无限阻塞。
配置示例和检查点:如何设置和验证限制
下面是一个信号量方案的简单示例。使用 golang.org/x/sync/semaphore 包提供的加权信号量。初始化 semaphore.NewWeighted(N),每个 goroutine 前调用 Acquire(context, 1) 获取资源,完成后 Release(1)。若资源不足,Acquire 会阻塞直到可用。判断条件可通过 TryAcquire 非阻塞尝试获取,返回 false 则表示已达上限。常见坑是 Acquire 后未正确 Release,导致资源永久耗尽。建议使用 defer 确保释放。另外,context 超时可防止永久阻塞,合理设置超时时间。检查时我会先跑一段压力测试,观察 pool.Running() 或 runtime.NumGoroutine() 是否稳定在 N 左右,再检查是否存在 leaked goroutine——用 pprof 看 goroutine profile 就清楚了。
回滚和风险:超时与资源泄漏
无论哪种方案,最怕的事情是 goroutine 泄漏。一旦泄漏,限制反而变成永久阻塞。我遇到过好几次:defer 写错位置,或者 Release 没被调用,导致 channel 或信号量被占满,后续所有请求全部 hang 住。这种时候只能重启进程。回滚手段:先撤掉限制代码,改用 runtime.GOMAXPROCS 做软限制(只控制并行不控制并发),或直接回退到旧版本。如果使用了 ants 或信号量,要确保 Release 放在 defer 里,且 context 超时时间不要设得太大。建议统一设成上游超时的一半,比如 HTTP 请求超时 5 秒,那 Acquire 的超时就设 2.5 秒。
后续维护:观察指标与容量规划
上线后要持续观察 runtime.NumGoroutine() 和 pool.Running() 的历史曲线。如果发现在业务高峰时长时间保持上限,说明 N 设小了——但不要立刻调大,先看响应时间是否变慢。响应时间正常可以调大,反之要检查外部依赖。另外,建议在日志里加入 goroutine 数采样,配合告警:如果连续 1 分钟 goroutine 数超过 N*1.2 且不回落,发警告。对于性能要求高的服务,可以用 pprof 定期抓取,确认没有 goroutine 在 wait 状态堆积。