gomaxprocs 默认等于逻辑 cpu 核心数,控制 p 的数量以限制并行度,通常无需手动调整;设为超过 numcpu 的值会增加调度开销,反而降低性能,仅在容器 cpu 配额受限等特殊场景下需显式设置。

Go 的并发不是靠“多线程模拟”,而是靠 GPM 三级调度模型实现在用户态完成的高效复用——它不依赖操作系统线程数量,也不靠锁抢资源,关键在于 P 如何把 G 分配给 M。
为什么 goroutine 创建快、切换便宜
因为 goroutine 是纯用户态对象,go func() 只是分配一个 g 结构体(初始栈仅 2KB),不触发系统调用;切换时只保存/恢复 PC 和 SP 两个寄存器,不用陷入内核。对比 OS 线程:每次切换要保存全部寄存器 + 内核栈 + TLB 刷新,开销高一个数量级。
常见错误现象:runtime: goroutine stack exceeds 1000000000-byte limit —— 通常是递归过深或闭包捕获大对象导致栈持续增长,而非“协程太多”。
- goroutine 栈按需扩张,上限默认 1GB,但实际很少用满
- 频繁创建/销毁 goroutine 不会直接导致 OOM,真正瓶颈常在 channel 缓冲区、未回收的 timer 或阻塞的系统调用
- 用
runtime.Stack查看单个 goroutine 栈大小,比盲猜更准
GPM 中 P 的数量直接影响吞吐和延迟
GOMAXPROCS 控制 P 的数量,默认等于 CPU 核心数。这不是“最大并发数”,而是“最大并行执行的逻辑处理器数”。P 太少,M 会排队等 P;P 太多,P 间负载不均 + 全局队列争抢变多。
使用场景举例:
- CPU 密集型服务(如图像处理):设为 CPU 核心数,避免上下文切换浪费
- IO 密集型服务(如 HTTP API):可略高于核心数(比如 +2),让部分 P 在等待网络响应时,其他 P 仍能处理新请求
- 混合型服务:用
pprof观察runtime/pprof/goroutine和runtime/pprof/scheduler,看procs和gcwaiting比例再调
注意:runtime.GOMAXPROCS(n) 是运行时可调的,但频繁修改会导致 P 重建开销,不建议在请求处理中动态改。
系统调用阻塞时 M 与 P 解绑的真实行为
当 goroutine 执行阻塞式系统调用(如 read、accept、time.Sleep),当前 M 会脱离 P,进入系统调用等待状态。此时 P 会立即寻找空闲 M(或新建一个)来继续执行队列里的 G —— 这就是 Go 能用少量线程支撑海量并发的关键。
但容易踩的坑:
- 调用非 Go 封装的 C 函数(如
cgo)且该函数阻塞,M 不会自动解绑,会卡住整个 P,拖慢其他 goroutine - 用
syscall.Syscall直接调用阻塞系统调用,同样绕过 Go 运行时封装,失去调度优势 - 即使用了
netpoller,某些文件 IO(如普通磁盘读写)仍是阻塞的,应改用io_uring(Go 1.22+)或异步包装
验证方式:用 go tool trace 查看 trace 文件中的 Proc Status,观察 M 是否长时间处于 syscall 状态而 P 无可用 M。
Work Stealing 不是万能的,本地队列满时才触发
每个 P 维护一个本地运行队列(LRQ),容量上限为 256 个 G。只有当 P 的 LRQ 为空,且全局队列也为空时,才会向其他 P “偷”一半 G。这意味着:如果所有 P 都长期满载,Work Stealing 不会发生;如果某 P 突然空闲,它不会主动“帮”忙,只会等别人来偷。
性能影响明显的情况:
- 短生命周期 goroutine 大量爆发(如每秒数万次
go http.HandlerFunc),容易挤爆 LRQ,溢出到全局队列,增加锁竞争 - 长耗时 goroutine 占据某个 P(如死循环或密集计算),导致该 P 无法分发新任务,其他 P 却可能空转
- channel 操作大量集中在单个 P 上(如多个 goroutine 同时往同一个无缓冲 channel 发送),会加剧该 P 的调度压力
简单优化:对高频 goroutine 启动,考虑用 worker pool 限流 + 复用,而不是无节制 go。
golang免费学习笔记(深入):立即使用
在学习笔记中,你将探索golang的核心概念和高级技巧!











