GMP 调度器
Go 的调度器是 M:N 调度器,多个 goroutine(G)映射到多个 OS 线程(M),通过逻辑处理器(P)调度。GMP 调度器实现了高效的并发和并行。
GMP 模型
G(Goroutine)
G 是 goroutine,Go 的用户态协程。G 包含:栈(初始 2KB)、程序计数器(当前执行的指令)、状态(运行、就绪、等待)。G 的状态切换:创建(_Gidle)、就绪(_Grunnable)、运行(_Grunning)、等待(_Gwaiting)、死亡(_Gdead)。
M(Machine)
M 是 machine,Go 对 OS 线程的抽象。M 包含:执行栈、G(当前运行的 G)、P(关联的 P)。M 可以阻塞(如系统调用),阻塞时 M 与 P 解绑,P 可以关联其他 M。
P(Processor)
P 是 processor,Go 的逻辑处理器。P 包含:本地 G 队列(runq,存储待运行的 G)、内存分配器(mcache)、GC 状态。P 的数量默认是 CPU 核心数,可以通过 GOMAXPROCS 调整。
调度流程
休眠状态
M0 是主线程(程序启动时创建),G0 是每个 M 的调度栈(不是用户 goroutine),P0 是主 P。初始时,P0 的本地队列为空,M0 与 P0 关联,M0 运行 G0(调度器代码)。
创建 G
创建 goroutine:go func() { ... }()。Go 运行时创建 G,G 的初始状态是 _Grunnable(就绪)。G 被添加到当前 P 的本地队列。如果本地队列满了(256 个),G 被添加到全局队列。
调度 G
M 从 P 的本地队列获取 G,如果本地队列为空,从全局队列获取 G(一次取多个)。如果全局队列也为空,从其他 P 的本地队列偷取 G(work stealing)。M 运行 G,G 的状态变为 _Grunning。
系统调用
G 执行系统调用(如文件 I/O),M 阻塞。M 与 P 解绑,P 可以关联其他 M(如果有空闲 M)。系统调用返回后,M 重新获取 P(可能是原来的 P,也可能是其他 P),继续运行 G。
如果系统调用时间过长(超过 20us),M 会被挂起,P 运行其他 G。系统调用返回后,M 重新关联 P,继续运行 G。
抢占式调度
Go 1.14 引入基于信号的抢占式调度。Go 运行时定期发送 SIGURG 信号给 M,M 收到信号后检查当前 G 是否执行时间过长,如果是,则挂起 G,调度其他 G。
抢占式调度防止某个 G 长时间占用 M,导致其他 G 饥饿。抢占式调度只针对用户代码,系统调用(如网络 I/O)本身就是可阻塞的。
工作窃取
本地队列优先
M 优先从关联的 P 的本地队列获取 G。本地队列的好处:不需要锁(单 M 访问)、CPU 缓存友好(数据局部性)。本地队列的大小是 256,超过则添加到全局队列。
全局队列
如果本地队列为空,M 从全局队列获取 G。获取 G 的数量:min(len(global), 61),一次获取多个 G,减少锁竞争。
工作窃取
如果本地队列和全局队列都为空,M 从其他 P 的本地队列偷取 G。偷取策略:从其他 P 的队列尾部偷取一半 G,避免频繁窃取。工作窃取保证了负载均衡,某个 P 的 G 很多,其他 P 可以窃取。
系统监控
Sysmon
Sysmon 是系统监控器,独立运行的 goroutine,定期检查:网络轮询器(netpoll)是否有就绪的 G、强制 GC(如果超过 2 分钟未 GC)、抢占长时间运行的 G(Go 1.13 之前)。
网络轮询器
网络轮询器监控网络文件描述符,当描述符就绪时,将关联的 G 放回全局队列。网络 I/O 不会阻塞 M,M 可以运行其他 G。网络轮询器基于 epoll/kqueue,高效监控大量描述符。
调度器优化
减少锁竞争
本地队列避免了全局队列的锁竞争。工作窃取时,只锁定被窃取的 P 的队列,不锁定所有 P 的队列。
减少上下文切换
Goroutine 切换只保存三个寄存器(SP、PC、DX),开销小(约 10-100ns)。线程切换需要保存所有寄存器,开销大(约 1-10us)。
充分利用多核
P 的数量默认是 CPU 核心数,每个 P 关联一个 M,充分利用多核。可以通过 GOMAXPROCS 调整 P 的数量,但不建议超过 CPU 核心数(会导致过多的上下文切换)。
常见问题
Goroutine 泄漏
Goroutine 泄漏是指 goroutine 无法退出,一直占用资源。常见原因:channel 阻塞(发送或接收无缓冲 channel)、死锁、无限循环。
避免 goroutine 泄漏:使用 context 取消 goroutine、使用 select + default 非阻塞发送、设置超时。
过多的 Goroutine
创建过多的 goroutine 会导致内存占用高(每个 goroutine 2KB)、调度开销大(工作窃取频繁)。解决方案:限制并发数(worker pool 模式)、使用缓冲 channel。
阻塞系统调用
阻塞系统调用(如文件 I/O)会阻塞 M,导致 P 无法运行其他 G。解决方案:使用 netpoller(网络 I/O)、使用非阻塞 I/O(O_NONBLOCK)。
GMP 调度器是 Go 高并发的核心,理解 GMP 的工作原理,有助于编写高效的 Go 程序,诊断性能问题。