P本地队列硬限256个G,超限则分裂一半至全局队列,触发锁竞争与调度延迟;该限制是runtime.runqput中硬编码逻辑,并非建议。

为什么P的本地队列(runq)必须控制在256个G以内
因为超过这个阈值,runtime会把一半G“甩”进全局队列,触发锁竞争和调度延迟。这不是性能建议,而是硬编码行为——runtime.runqput里明确写了if len(p.runq) > 256就执行分裂。
- 本地队列满后分裂,会导致新G被塞进全局队列,而调度器优先从本地取任务,全局队列里的G要等本地空了才轮到,延迟可能达几十次调度周期
- 全局队列操作需加
runtime.runqlock,高并发起goroutine时容易卡在锁上——压测中见过runtime.lock占trace总时间12% - 别指望“多起几个G就能更快”,如果HTTP handler里循环
go handle()且不带限流,很快所有P的本地队列都满,后续G全进全局队列,吞吐反而断崖下跌
work-stealing不是万能的:P之间偷任务的代价在哪
偷任务发生在本地队列为空时,M会从其他P队列尾部拿一半G过来。但这个过程不是零成本——它需要原子操作、缓存行失效,且依赖P间内存布局是否紧凑。
- 物理CPU核心距离远的P(比如跨NUMA节点),偷任务延迟明显升高;
go tool trace里能看到StealWork事件耗时突增 - 偷来的G若刚被唤醒(比如channel send完成),可能立刻又阻塞,导致刚偷来的G又得挂起,白忙一场
- 偷任务只发生在M空闲时;如果所有M都在忙,哪怕某个P队列堆满G,其他P也看不到——这不是bug,是设计:避免无谓的跨P同步开销
runtime.GOMAXPROCS()改的是P数,不是线程数,这点必须分清
很多人调大GOMAXPROCS以为能“开更多线程”,结果发现M没变、系统调用阻塞依旧卡顿——因为P只是逻辑调度单元,不直接创建OS线程。
使用 uber-go/dig 在 Golang 中实现依赖注入——基于反射容器,提供 Provide/Invoke、dig.In/dig.Out 参数与结果对象,支持命名依赖等
-
GOMAXPROCS(8)意味着最多8个P可并行执行用户代码,但M数量由实际负载决定:一个P上G全阻塞时,runtime可能拉起新M来接管它 - 设为1后,所有G挤在单个P的本地队列里,即使有100个I/O就绪的G,也得等当前G跑完才能轮到——
netpoll就绪事件会被暂存,不主动触发调度 - 修改必须在
main()开头做;在goroutine里调runtime.GOMAXPROCS(4)大概率静默失败,Go 1.23+还会panic
LockOSThread()让M独占P,但P不会消失,这是最易被忽略的陷阱
调用runtime.LockOSThread()后,当前M不再参与调度,但它持有的P也不会释放——其他G无法使用这个P,等于人为制造了一个“不可复用”的调度孤岛。
立即学习“go语言免费学习笔记(深入)”;
- CGO调用C库时若发生阻塞(如
sleep(5)或等待stdin),该M+P组合就卡住,整个P上的其他G全部饿死 - 必须配对调用
runtime.UnlockOSThread();漏掉一次,M永久标记为locked,最终触发runtime: program exceeds maximum number of OS threads - 即便你只在一小段C调用前后加Lock/Unlock,也要注意中间是否隐含阻塞点——比如C函数内部调用了
getaddrinfo(),它底层是同步DNS查询,一样会卡住M

















