一、什么是GMP?
GMP 是 Go 语言的调度模型,是 Go 语言的并发模型。

源码:GMP
二、GMP 模型
GMP 模型 是 Go 语言调度器的核心模型,它是 Go 语言调度器的基础。
| 组件 | 说明 |
|---|---|
| G(Goroutine) | 调度的基本单位,由Go的运行时管理,比OS线程更轻量的协程,包含栈和相关上下文的信息,在Golang中 使用 go 开启一个协程 |
| M(Machine) | 代表一个执行线程或系统线程,是真正工作的单位,M中保存自身使用的栈信息、当前M正在执行的G的信息与其绑定的P的信息 |
| P(Processor) | 代表一个逻辑处理器,维护了一个处于可运行状态的G队列,每一个M都有一个P对应关联,P的数量是固定的,由GOMAXPROCS决定,默认为CPU核数 |
- 本地运行队列(Local Runnable Queue,LRQ):存放当前 P 的 G,每个 P 都有一个本地队列, 用于存放当前 P 等待和正在运行的 G,每个 P 的本地队列中最多存放 256 个 G 。创建 G 时,会优先放入本地队列,如果本地队列满了,则会将队列中一半的 G 移动到全局队列中。
- 全局运行队列(Global Runnable Queue,GRQ):存放正在等待的 G,即没有在LRQ中的 G,
- M 的数量是动态的,由调度器决定。根据当前系统的负载自动调整,M 像运行任务,就需要获取 P ,如何没有 P 那 M 就会阻塞。如果 P 的本地队列为空,M 就会从全局队列中获取 G 放入本地队列,如果全局队列也为空,M 就会从其他随机一个 P 的本地队列中获取一半的 G 放到当前 P 的本地队列中。
三、G、M、P 各自的特点:
G(Goroutine)
- 当 goroutine 被调离 CPU 时,调度器负责把 CPU 寄存器的值保存在 g 对象的成员变量之中。 当 goroutine 被调度起来运行时,调度器又负责把 g 对象的成员变量所保存的寄存器值恢复到 CPU 的寄存器。
M(Machine)
-
设置M的数量
- runtime/debug 中的 SetMaxThreads 函数,设置 M 的最大数量
-
M:N模型
-
M 个 goroutines (G) 被调度到 N 个内核线程 (M) 上执行,这些线程运行在最多
GOMAXPROCS个逻辑处理器 (P) 上。 -
P 负责管理 G 的本地运行队列(LRQ),每个 P 必须绑定一个 M 才能执行 G。如果某个 M 阻塞(如系统调用),与该 M 绑定的 P 会解绑,并由其他空闲的 M(或新建的 M)接管这个 P,继续执行其 LRQ 中的 G。
-
-
M阻塞和G阻塞的区别
维度 M 阻塞(线程级) G 阻塞(协程级) 触发原因 系统调用、CGO 等底层操作 Channel、锁、Gosched() 等用户态操作 阻塞对象 内核线程(M) Goroutine(G) 调度影响 P 与 M 解绑,可能创建新 M G 被移出运行队列,M 执行其他 G 恢复机制 系统调用完成后重新绑定 P 条件满足后重新加入运行队列 性能代价 高(线程切换、内核态开销) 低(用户态调度,无线程切换) -
M 的自旋状态
自旋(Spinning) 是指 M 在暂时没有可执行的 G(Goroutine)时,不立即进入休眠,而是空转循环(忙等待),主动寻找可运行的 G。 自旋的 M 会占用 CPU 资源,但避免了线程休眠和重新唤醒的代价(系统调用、上下文切换等)。
一个 M 进入自旋状态通常需要满足以下条件:
- 当前 M 绑定的 P 的本地运行队列(LRQ)为空,且无法立即从全局队列(GRQ)或其他 P 偷取到 G。
- 仍有其他 P 正在运行 G(即系统整体有任务可执行,只是当前 P 暂时无任务)。
- 自旋的 M 数量未超过阈值(默认最多 GOMAXPROCS/2 个自旋 M,防止过度占用 CPU)。
自旋的 M 会持续执行以下操作:
- 检查全局队列(GRQ):
- 定期扫描 GRQ,看是否有新加入的 G 可执行。
- 尝试从其他 P 偷取 G(Work Stealing):
- 随机选择其他 P,从其 LRQ 中偷取一半的 G(最少偷 1 个)。
- 检查网络轮询器(Netpoller): 查看是否有就绪的网络 I/O 相关的 G 可恢复执行。
- 自旋超时后休眠: 如果自旋一段时间(约 10ms)仍找不到 G,M 会退出自旋状态并休眠。
P(Processor)
- 当一个线程阻塞的时候,将和它绑定的 P 上的 G 转移到其他线程。Go scheduler 会启动一个后台线程 sysmon,用来检测长时间(超过 10 ms)运行的 goroutine,将其调度到 global runqueues。这是一个全局的 runqueue,优先级比较低,以示惩罚。
- 设置P的数量
- P 的数量是固定的,由启动时环境变量
GOMAXPROCS或者是由runtime的方法GOMAXPROCS()决定。这意味着在程序执行的任意时刻都只有GOMAXPROCS个 goroutine 在同时运行。
- P 的数量是固定的,由启动时环境变量
P 和 M 的创建时机。
- P 在系统启动根据
GOMAXPROCES创建 n 个 P。 - M 在 没有足够的 M 来关联 P 并运行其中可运行的 G 时,例如:M 在阻塞状态,但是 P 中还有空闲任务,就会去寻找空闲的 M ,如果没有空闲的 M,就会创建新的 M。
四、GMP调度流程
什么是Go的调度器?

源码:proc.go
- Go scheduler 是 Go runtime 的一部分,它内嵌在 Go 程序里,和 Go 程序一起运行。因此它运行在用户空间,在 kernel 的上一层。
- 程序运行时会启动一些 G:垃圾回收的 G,执行调度的 G,运行用户代码的 G (main 也是一个 G );并且会创建一个 M 用来开始 G 的运行。随着时间的推移,更多的 G 会被创建出来,更多的 M 也会被创建出来。
调度器的设计策略
- 复用线程:避免频繁的创建、销毁线程。
- work stealing机制(盗用机制):当本地运行队列为空时,尝试从其他 P 的运行队列偷取 G,而不是直接销毁线程 (M)。
- hand off 机制(交接机制):当本线程因为 G 进行系统调用阻塞时,线程释放绑定的 P,把 P 转移给其他空闲的线程执行。
- 并行:通过设置
GOMAXPROCS配置 P 的数量,从而达到并行的目的,当 P 达到CPU核心数时,达到最大的并行度。 - 抢占式调度:在 coroutine 中,需要等待一个协程主动让出CPU才能执行下一个协程,Go规定每个 goroutine 只会持有CPU 10ms,防止其他协程被饿死。
调度器的生命周期

M0 和 G0
- M0 是 程序启动后编号为0的主线程,这个 M 对应的实例会在全局变量 runtime.m0(定义在 proc.go 的 m0) 中,M0 负责执行初始化操作和启动第一个 G。
- G0 是每次启动一个 M 都会第一个创建的 gourtine,G0 仅用于负责调度的 G,G0 不指向任何可执行的函数,每个 M 都会有一个自己的 G0。在调度或系统调用时会使用 G0 的栈空间,全局变量的 G0 (proc.go 的 g0) 是 M0 的 G0。
go func() 的调度流程

- 编译器将
go func()转换成对runtime.newproc的调用(在runtime/proc.go 中)。 - newproc : 切换到 g0 栈调用
newproc1。 - newproc1 :
- 从
p.gfree中 获取一个空闲的g,如果没有就从全局队列中获取一批(proc.go 的gfget函数)。 - 初始化 G 的栈和上下文(SP, PC)。这里将 PC 设置为了
goexit,以便 G 跑完后能自动退出。
- 从
- 调用
runqput将新 G 放入当前 P 的本地队列。如果满了,则放入全局队列。 - 尝试再加一个P来执行G。当 G 可运行(newproc、ready)时调用(proc.go 的 wakep 函数)
代码演示
package main
import "fmt"
func main() {
fmt.Println("Hello world")
}
- runtime 创建最初的线程 m0 和 goroutine g0,并把 2 者关联。
- 调度器初始化:初始化 m0、栈、垃圾回收,以及创建和初始化由
GOMAXPROCS个 P 构成的 P 列表。 - 示例代码中的 main 函数是
main.main,runtime中也有 1 个 main 函数 ——runtime.main,代码经过编译后,runtime.main会调用main.main,程序启动时会为runtime.main创建 goroutine,称它为 main goroutine 吧,然后把 main goroutine 加入到 P 的本地队列。 - 启动 m0,m0 已经绑定了 P,会从 P 的本地队列获取 G,获取到 main goroutine。
- G 拥有栈,M 根据 G 中的栈信息和调度信息设置运行环境。
- M 运行 G。
- G 退出,再次回到 M 获取可运行的 G,这样重复下去,直到
main.main退出,runtime.main执行 Defer 和 Panic 处理,或调用runtime.exit退出程序。
感兴趣可以追踪一下 runtime.main()
调度循环
Go 调度器的核心逻辑几乎全都在
src/runtime/proc.go文件中。
schedule 函数,它是整个调度器的核心。
大致流程如下:
- 检查全局队列:为了公平,每隔 61 次调度,会去全局队列(
sched.runq)拿一个 G。 - 检查本地队列:调用
runqget()从当前 P 的本地队列获取 G。 - 工作窃取 (Work Stealing):如果本地没有,调用
findrunnable()。这个函数极其复杂,它会尝试从全局队列拿、从网络轮询器(Netpoller)拿、或者从其他 P 的队列里“偷”一半 G 过来。 - 执行:找到 G 后,调用
execute(g, inheritTime)。
上下文切换
Go 如何从“调度逻辑”切换到“用户代码”?
根据架构不同,go 提供了不同的汇编源码,以 asm_amd64.s 为例
gogo 函数
- 在
proc.go的execute()函数最后,会调用gogo(&g.sched)。gogo是一个汇编函数。 - 它利用
g.sched中保存的信息,恢复寄存器(RSP, RIP 等)。 - 最后一条指令是
JMP(arm 的跳转指令是 B ,代表无条件跳转,B. 代表有条件跳转),直接跳到用户代码的位置。至此,由 G 开始执行。 - 当 G 需要让出 CPU(比如调用了
time.Sleep或 阻塞在 Channel 上)时,不能直接切过去,必须先切回g0。
mcall 函数
mcall(fn)也是汇编实现。- 它保存当前 G 的上下文到
g.sched。 - 切换栈指针 (SP) 到
g0的栈。 - 在
g0栈上执行回调函数fn(例如park_m),最终再次调用schedule()进行下一轮调度。
参考