goroutine泄漏比性能差更危险,因阻塞的goroutine持续占用资源;不可复用goroutine,应构建带缓冲channel的worker pool;GOMAXPROCS默认无需调整,仅容器限CPU时需适配;channel争用是协作瓶颈,宜用缓冲channel或fan-in/fan-out。

goroutine 泄漏比性能差更危险
Go 程序中盲目起 go func() {...}() 是最常见也最隐蔽的性能隐患。goroutine 本身开销极小(初始栈仅 2KB),但一旦阻塞在未关闭的 channel、空 select、或死循环里,它就永远卡在 _Gwaiting 或 _Grunnable 状态,不释放栈、不回收调度器资源,最终拖垮整个程序。监控 runtime.NumGoroutine() 持续上涨,基本可断定存在泄漏。
用 sync.Pool 复用 goroutine 本身是错的
goroutine 不是对象,不能“复用”——每次 go 关键字都会新建一个,并由 Go 调度器统一管理生命周期。所谓“复用”,实际是指:避免为每个短期任务都新建 goroutine,而是把任务投递到固定数量的 worker goroutine 中执行。正确做法是构建 worker pool:
type WorkerPool struct {
jobs chan func()
done chan struct{}
}
func NewWorkerPool(n int) *WorkerPool {
p := &WorkerPool{
jobs: make(chan func(), 1024),
done: make(chan struct{}),
}
for i := 0; i < n; i++ {
go p.worker()
}
return p
}
func (p *WorkerPool) Submit(job func()) {
select {
case p.jobs <- job:
default:
// 可选择丢弃、阻塞、或 panic,依场景而定
}
}
func (p *WorkerPool) worker() {
for {
select {
case job := <-p.jobs:
job()
case <-p.done:
return
}
}
}
-
jobschannel 必须带缓冲,否则 Submit 可能阻塞调用方;容量需根据任务平均耗时和吞吐预估 - worker 内部不能有未处理的 panic,否则该 goroutine 退出,pool 缩容失效
- 没有内置的“任务超时”或“取消”机制,如需支持 context,得把
func()改成func(context.Context)
什么时候该用 runtime.GOMAXPROCS 调整?
GOMAXPROCS 控制的是 P(Processor)的数量,即能并行执行用户代码的操作系统线程数上限。默认等于 CPU 核心数,99% 的场景下不应手动修改:
- 设得过小(如
1):即使有 1000 个 ready 状态 goroutine,也只能串行跑,严重浪费多核 - 设得过大(如 >256):P 之间切换开销上升,且 runtime 内部的全局锁(如 sched.lock)争抢加剧
- 唯一合理调整时机:容器环境(如 Docker)限制了 CPU quota,此时应设为
cpu.Quota() / cpu.Period()向下取整,避免调度器误判可用算力
channel 操作是 goroutine 协作瓶颈点
大量 goroutine 往同一个无缓冲 channel 发送,或从同一 channel 接收,会引发严重的锁竞争——因为 channel 的 send/recv 都需加锁操作其内部的 recvq 和 sendq。实测在 64 核机器上,1000 个 goroutine 同时向一个无缓冲 channel 写入,吞吐可能不足 10k ops/s。
立即学习“go语言免费学习笔记(深入)”;
- 优先使用带缓冲 channel:
make(chan T, N),N 至少为并发写 goroutine 数 × 平均每秒任务数 × 期望缓冲秒数 - 避免“一写多读”或“多写一读”模型;改用 fan-in/fan-out:每个 worker 自己的 input channel + 一个集中 dispatcher
- 对超高频信号通知(如心跳、开关),用
sync.Map+atomic比 channel 更轻量
真正卡住 goroutine 性能的,从来不是创建开销,而是协作原语(channel、mutex、atomic)的争用模式和生命周期管理。别想着“复用 goroutine”,要想清楚“谁该等谁、等多久、等不到怎么办”。











