背景
在一次 Go 项目的性能优化中,我遇到了一个典型但容易被忽视的问题:
并发开太猛,单机反而跑得更慢,甚至影响系统稳定性。
该项目在启动阶段需要执行一批重计算 / 重 IO 的构建任务(例如自动机构建、规则编译、索引初始化等)。最初的实现思路非常直接:
- 每个任务一个 goroutine
- 尽可能并行,加快启动速度
但在真实环境中,这种"无脑并发"带来了明显副作用:
- CPU 使用率瞬间拉满
- 内存抖动严重,频繁 GC
- 其他模块启动被抢占资源
- 启动时间 不降反升
于是我开始重新审视一个问题:
并发 ≠ 无限 goroutine,单机资源是有上限的
问题分析:为什么无限并发会变慢?
Go 的 goroutine 非常轻量,但并不免费:
- goroutine 本身需要调度
- 大量并发会导致 调度器竞争
- CPU cache 命中率下降
- 内存分配与 GC 压力骤增
- IO 任务同时发起,反而排队
本质原因是:
任务数 ≫ CPU / IO 能承载能力
所以真正合理的目标不是"并发越多越好",而是:
让并发数 ≈ 系统最优吞吐点
设计目标
在启动阶段引入一种简单、可控、低侵入性的流量控制手段,目标是:
- 限制单机并发 goroutine 数量
- 防止 CPU / 内存被瞬间打爆
- 不影响业务代码结构
- 便于调整与扩展
最终选择了 Go 标准库中的一个"老而稳"的方案:semaphore(信号量)
实现方案
定义全局 semaphore
go
// 限制单机最大并发数
var sem = make(chan struct{}, 20)
这里的 20 并不是拍脑袋来的,而是结合:
- CPU 核心数
- 单任务 CPU / 内存占用
- 实际压测结果
得到的一个相对稳定区间值。
在任务执行前获取 semaphore
go
func runTask(task Task) {
sem <- struct{}{} // 获取令牌
go func() {
defer func() {
<-sem // 释放令牌
}()
task.Run()
}()
}
这样可以保证:
- 同时运行的 goroutine ≤ 20
- 多余任务会自然阻塞在获取 semaphore 阶段
- 不会造成 goroutine 洪水
启动阶段并发构建示意
go
for _, task := range tasks {
runTask(task)
}
整体结构几乎没变,但系统行为发生了本质变化。
优化效果
在引入 semaphore 后,对比效果非常明显:
| 指标 | 优化前 | 优化后 |
|---|---|---|
| 启动耗时 | 十几分钟 | 分钟级 |
| CPU 使用率 | 长时间 100% | 稳定在合理区间 |
| 内存占用 | 峰值过高 | 波动明显下降 |
| 其他模块启动 | 被阻塞 | 正常并行 |
| 系统稳定性 | 偶发 OOM / 卡死 | 明显提升 |
关键点在于:
限流后,单个任务反而执行得更快了
整体吞吐提升,而不是下降
一些实践经验总结
semaphore 非常适合"启动期流量控制"
- 启动阶段任务密集
- 容错要求低于在线请求
- 非常适合用硬限流兜底
并发上限 ≠ CPU 核数
- CPU 密集型:接近核数
- IO 密集型:可略高
- 混合型:需要压测
semaphore 是"最后一道保险"
即使上层已经做了拆批 / 分阶段执行:
semaphore 仍然是防止误配置、误改代码的安全网