Go调度器自动完成goroutine调度,关键在于避免破坏其负载均衡机制:新goroutine优先入当前P本地队列(上限256),超量则打乱后入全局队列;P失衡、阻塞系统调用、LockOSThread滥用或全局队列积压均会导致多核利用率低下。

Go 调度器不是“手动调度”成千上万个 goroutine 的——它自动完成,但前提是你的代码不破坏调度器的负载均衡机制。关键不在“怎么调”,而在“别怎么搞坏它”。
goroutine 创建后去哪儿了?
每次 go f(),新 goroutine 优先入当前 P 的本地队列(runq),容量上限是 256。超过这个数,运行时会把本地队列前半部分 + 新 goroutine 打乱后塞进全局队列。
- 本地队列访问无锁、快,适合短任务;全局队列有锁,只在本地空或批量迁移时用
- 若所有 goroutine 都集中在单个 P(比如
runtime.GOMAXPROCS(1)),再多 goroutine 也跑不满多核 - 阻塞系统调用(如
os.ReadFile、net.Conn.Read)会让 M 解绑 P,此时 P 可能被其他空闲 M 接管——这是调度器维持并发的关键动作
为什么开了几万 goroutine,CPU 还不到 30%?
常见原因是 P 之间严重失衡:某些 P 的本地队列长期空转,而其他 P 堆满任务却没人来偷。这通常由以下行为触发:
- 大量 goroutine 同时执行长阻塞操作(如未设超时的
http.Get),导致对应 M 长期解绑,P 暂时“失联” - 密集使用
runtime.LockOSThread(),把 goroutine 绑死在某个 M 上,绕过 P 的调度逻辑 - 全局队列积压太多(
schedtrace显示globrun持续高),说明本地队列长期取空,窃取失败或窃取频率太低
验证方式:GODEBUG=schedtrace=1000 启动程序,观察日志中各 P 的 runqueue 长度是否差异巨大。
立即学习“go语言免费学习笔记(深入)”;
如何让调度器真正摊开负载?
核心是减少“粘性”,增强工作窃取(work stealing)生效机会:
- 避免长时间纯 CPU 计算:每 10ms 左右主动调用
runtime.Gosched()让出,或拆成小段任务 - 系统调用务必加超时:比如
conn.SetReadDeadline()、http.Client.Timeout,防止 M 卡死 - 慎用
sync.Pool存储大对象——若对象过大,GC 扫描压力上升,间接拖慢调度器后台线程(sysmon) - 不要人为限制
GOMAXPROCS到远低于 CPU 核心数,除非你明确要限流(比如测试场景)
一个典型反例:for i := 0; i —— 这些 goroutine 全进等待状态,不占 CPU,也不触发窃取,看似“开了 1 万”,实际对调度器零压力。
goroutine 太多,真的会崩吗?
内存层面不会轻易崩:每个 goroutine 初始仅 2KB 栈,10 万个约 200MB;但栈动态扩容后可能陡增——尤其递归深或局部变量大时,runtime: goroutine stack exceeds 1000000000-byte limit 就会出现。
- 更现实的瓶颈是 GC 压力:goroutine 对应的
g结构体本身需被扫描,数量级上百万时,STW 时间可能从毫秒级跳到百毫秒级 - M 数量默认上限 10000(
debug.SetMaxThreads可调),但真到这个数,往往说明有大量 goroutine 卡在 syscall 或 cgo,该查阻塞点了 - 调度器本身不维护 goroutine 全局列表,所以“遍历所有 goroutine”这种操作不存在——这也是它能支撑百万级的基础
真正危险的不是“多”,而是“卡住不动还占着资源”:比如忘了关 channel、死锁的 select、没 cancel 的 context。这些会让 goroutine 永久处于 waiting 状态,最终耗尽内存或触发 OOMKilled。


















