gmp模型通过解耦g、m、p实现高并发:g轻量(2kb栈)、按需扩缩;m为os线程,仅在必要时创建;p控制逻辑处理器数量(默认=cpu核数),支持work-stealing与系统调用解绑,避免阻塞。

GMP 模型不是靠“多线程”堆出并发性能,而是靠解耦 G、M、P 三者关系,让调度器能动态复用有限的 OS 线程(M),同时支撑海量 Goroutine(G)的轻量调度。
为什么 goroutine 创建快、数量多却不压垮系统
G 的初始栈只有 2KB,且按需动态扩缩;创建时只分配结构体和栈头,不立即向 OS 申请线程。对比 OS 线程(通常默认栈 1~8MB),go func(){}() 的开销极低。实际中轻松起 10 万 G 不会触发 OOM,但若换成等量 pthread,早因内存和调度器负载崩掉。
关键点在于:G 生命周期完全由 runtime 管理,不绑定 M —— 这意味着:
- G 阻塞(如
ch 、<code>time.Sleep、net.Read)时,M 可立刻脱离并去跑别的 G,P 不丢、队列不断 - 大量 G 处于等待状态时,M 数量不会线性增长;运行时只在真正需要时才新建 M(比如所有 P 都有可运行 G,但当前无空闲 M)
- 全局
runtime.GOMAXPROCS控制的是 P 的数量(默认 = CPU 核心数),而非 M 或 G 的上限
为什么 M 不固定绑 G,却能避免频繁上下文切换
M 是 OS 线程,切换代价高;G 是用户态协程,切换在 runtime 内完成,仅需保存/恢复几个寄存器和栈指针。调度器让每个 M 尽量从自己绑定的 P 的本地队列取 G 执行 —— 这带来两个好处:
- 本地队列访问无锁,减少竞争;90%+ 的 G 调度走本地队列路径,极快
- 当本地队列空了,M 才去全局队列或“偷”其他 P 队列尾部一半的 G(work-stealing),既保局部性,又实现负载均衡
- sysmon 监控线程会定期检查是否某个 G 运行超时(默认 10ms),通过栈抢占(
stackPreempt)强制切走,防止饿死其他 G
系统调用阻塞时,P 怎么不被卡住
这是 GMP 区别于简单 N:1 协程模型的关键。当 G 发起阻塞式系统调用(如 read()),M 会与 P 解绑,把 P 让给其他空闲 M 使用;原 M 则单独阻塞等待系统调用返回。返回后:
- 若能立刻拿到空闲 P,M 就继续执行该 G
- 若拿不到 P(比如所有 P 都忙),该 G 会被扔进全局队列,等任意 M+P 组合来捞它
- 整个过程不阻塞 P,也不浪费其他 M —— 所以即使有上万个 G 在等磁盘 IO,CPU 依然能跑满
注意:runtime.LockOSThread() 是个例外,它会让当前 G 和 M 强制绑定,P 被独占,此时若 G 长时间阻塞,那个 P 就彻底废了,慎用。
什么时候 GMP 反而拖慢性能
不是所有场景都受益于 GMP。以下情况容易踩坑:
- 大量短生命周期 G(比如每次 HTTP 请求只起一个
go handle(),但 handle 里全是同步计算),会导致频繁调度和队列搬运,不如直接在当前 goroutine 做完 -
GOMAXPROCS设得远高于物理核数(比如 64 核机器设成 256),P 过多引发更多 work-stealing 和全局队列争抢,反而降低吞吐 - 滥用
runtime.LockOSThread()后忘记配对UnlockOSThread(),导致 M 泄漏,最终 hitruntime.SetMaxThreads上限(默认 10000),新 G 无法调度 - channel 操作未配平:大量 goroutine 在同一 channel 上收发,会挤在 sudog 链表里排队,变成串行瓶颈,和 GMP 无关,是设计问题
真正影响性能的从来不是 G 的数量,而是 G 的阻塞模式、P 的负载分布、以及 M 是否被非必要地独占 —— 这些细节在 pprof 的 goroutine 和 threadcreate profile 里才看得清。
golang免费学习笔记(深入):立即使用
在学习笔记中,你将探索golang的核心概念和高级技巧!











