GMP model scheduler

·go
#underlying-principle

为什么要有 GMP 调度器?

轻量级并发:

Go 的 goroutine 数量可以轻松达到几十万、上百万,而直接用操作系统线程(OS Thread)创建这般规模并发在多数平台上会内存耗尽或调度过慢

资源复用:

将数以万计的 goroutine 复用到有限的线程上,通过调度提高 CPU 利用率并减少上下文切换开销

可控并行度:

Go 允许你用 GOMAXPROCS 精准控制最大并行执行的核心(Processor),既能发挥多核优势,也可避免过度争抢

GMP 三要素

G(Goroutine)

Go 语言的轻量级执行单元,你在代码中 go f() 启动的就是一个 G

每个 G独立的栈(初始仅几 KB),以及保存执行上下文(程序计数器、寄存器状态等)的调度结构体

M(Machine)

底层的执行实体,实际上对应一个操作系统线程OS Thread

真正执行 G 中代码需要绑定到某个 M

P(Processor)

虚拟的执行上下文,负责管理 G 的运行队列,并将 G 派发给 M 执行

P 的数量由环境变量或运行时调用 runtime.GOMAXPROCS(n) 决定,默认与机器 CPU 核数相同

要素数量职责
G动态增长保存协程上下文、栈、状态
M动态调整绑定 OS 线程,执行 G
P固定持有本地运行队列,调度 G

调度流程概览

启动:

当你调用 go f() 时,运行时创建一个新的 G,将其放到某个 P 的本地运行队列(run queue)

派发:

如果当前 P 空闲且已有 M 绑定,P 会把队列里头部的 G 派给那个 M,让 M 在线程上下文中执行

抢占:

G 长时间运行(如 CPU 密集型循环)、或在系统调用/阻塞操作后,调度器会抢占它,把执行权交回调度,以保证公平与响应性

工作窃取:

若某个 P 的本地队列空了,它会去其他 P 的队列“偷”一半 G 来执行,保持负载均衡

系统监视:

专门的 sysmon MP 周期性检查网络 I/O 阻塞、垃圾回收、定时器等,并唤醒相应的 GM

关键组件深度剖析

本地运行队列(runq)与全局队列

本地队列(每个 P 一个)存放刚创建或偷来的 G,长度有限(约 256 或 512)

全局队列:当本地队列满时,新的 G 会入全局队列,或当本地队列空且无法从其他 P 偷到时,从全局队列拉 G

好处:局部性高,调度快速;全局队列保证不会因某个 P 队列过满而丢失任务

工作窃取(Work Stealing)

P 会定期查看自己队列,如果空了就随机选择另一个 P,从中间取走大约一半未执行的 G,放入自己队列

优势:动态负载平衡,避免某个核过载而其他核空闲

抢占式调度

为了防止某个 G 长时间占用线程,Go 1.14+ 引入了异步抢占

运行时在函数调用边界或 loop 中插入检查点,若检测到抢占标志,就在合适位置强制让出 CPU

效果:即便 G 中有死循环,也能及时切换,提升响应能力

sysmon(System Monitor)

一个专门的 M(通常只有一个)负责监控阻塞操作、定时器到期、网络轮询结果

当网络 I/O 完成时,sysmon 会把对应在网络阻塞队列中的 G 放回调度队列,并唤醒 M 执行

如何使用与调优

控制并行度:GOMAXPROCS
import "runtime"

func main() {
    // 限制并行数为 4
    runtime.GOMAXPROCS(4)
    // ...
}

场景:

  • CPU 密集型:适当设置接近 CPU 核数,避免过度上下文切换

  • I/O 密集型:可适当调高,让更多 G 发起 I/O 请求,但会占用更多线程与内存

注意:动态调整会触发全局 stop-the-world,谨慎在生产中频繁修改

锁栈与 Cgo

默认情况下,Go runtime 可将 G 在多个 M 之间迁移

若你调用 runtime.LockOSThread(),就把当前 G “锁定”到当前 M不再被迁移,常见于:

  • 与 C 库(Cgo)交互时要求在同一线程中调用

  • GUI 库(如 OpenGL、X11)需要固定线程上下文

例如:

func main() {
    runtime.LockOSThread()
    defer runtime.UnlockOSThread()
    // 在这之后的 Cgo 调用都在同一 OS 线程
}
避免过度阻塞

系统调用、长时间的 Cgo 调用或文件 I/O,会让所在 M 阻塞

Go 会自动分配新的 M 来保持并发,但频繁阻塞/创建线程会带来上下文切换开销

解决:将阻塞操作隔离在少量专用 goroutine,或使用 Go 的异步 I/O(如 netpoll),保持 M 空闲

典型使用场景

高并发网络服务器

上万并发连接对应上万 goroutine,通过 GMP 调度高效复用少量线程

sysmon + netpoll 实现极低延迟的事件驱动

数据处理流水线

多阶段计算,各阶段用 goroutine 池(worker pool)+ channel 传递,GMP 在后台平衡负载

GOMAXPROCS 设置可控制 CPU 核心利用率,保持稳定吞吐

定时任务与心跳监控

time.Aftertime.Ticker 所产生的定时 G,交由 sysmon 唤醒,精准调度

与 C/C++ 库互操作

需要 LockOSThread 的场景下,GMP 也能保证其他 G 在其他 M 上继续并发执行

排查与最佳实践

性能剖析:

go tool pprof 检查 goroutine 数量、线程数、GC 暂停。看是否有大量 G 堆积或频繁创建 M

检测竞争:

go test -race 检测竞态,避免因锁竞争导致大量阻塞

日志与指标:

在关键路径记录 runtime.NumGoroutine()NumCPU()GOMAXPROCS 等指标,对比业务峰谷

合理设置 GOMAXPROCS

生产环境中先压测找最佳值,避免简单把它调到最大或最小

小结

Go 的 GMP 调度器巧妙地将海量的 goroutine 映射到有限的操作系统线程上,通过本地队列、工作窃取和系统监视,既保证了高并发轻量级调度,又能动态均衡负载

掌握它,你就能:

  • 合理控制并行度,提升 CPU 利用率

  • 避免因阻塞或 Cgo 调用导致的性能瓶颈

  • 针对不同业务场景(网络、计算、定时等)做出有效调优