加载中...

Golang GMP调度模型流程

一、什么是GMP?

GMP 是 Go 语言的调度模型,是 Go 语言的并发模型。

gmp-model.png

源码: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 解绑,可能创建新 MG 被移出运行队列,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 和 M 的创建时机。

  • P 在系统启动根据GOMAXPROCES 创建 n 个 P。
  • M 在 没有足够的 M 来关联 P 并运行其中可运行的 G 时,例如:M 在阻塞状态,但是 P 中还有空闲任务,就会去寻找空闲的 M ,如果没有空闲的 M,就会创建新的 M。

四、GMP调度流程

什么是Go的调度器?

go-scheduler.png

源码: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,防止其他协程被饿死。

调度器的生命周期

go-schedule-life.png

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() 的调度流程

gofunc-workflow.png

  • 编译器将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")
}
  1. runtime 创建最初的线程 m0 和 goroutine g0,并把 2 者关联。
  2. 调度器初始化:初始化 m0、栈、垃圾回收,以及创建和初始化由 GOMAXPROCS 个 P 构成的 P 列表。
  3. 示例代码中的 main 函数是 main.mainruntime 中也有 1 个 main 函数 —— runtime.main,代码经过编译后,runtime.main 会调用 main.main,程序启动时会为 runtime.main 创建 goroutine,称它为 main goroutine 吧,然后把 main goroutine 加入到 P 的本地队列。
  4. 启动 m0,m0 已经绑定了 P,会从 P 的本地队列获取 G,获取到 main goroutine。
  5. G 拥有栈,M 根据 G 中的栈信息和调度信息设置运行环境。
  6. M 运行 G。
  7. G 退出,再次回到 M 获取可运行的 G,这样重复下去,直到 main.main 退出,runtime.main 执行 Defer 和 Panic 处理,或调用 runtime.exit 退出程序。

感兴趣可以追踪一下 runtime.main()

调度循环

Go 调度器的核心逻辑几乎全都在 src/runtime/proc.go 文件中。

schedule 函数,它是整个调度器的核心。

大致流程如下:

  1. 检查全局队列:为了公平,每隔 61 次调度,会去全局队列(sched.runq)拿一个 G。
  2. 检查本地队列:调用 runqget() 从当前 P 的本地队列获取 G。
  3. 工作窃取 (Work Stealing):如果本地没有,调用 findrunnable()。这个函数极其复杂,它会尝试从全局队列拿、从网络轮询器(Netpoller)拿、或者从其他 P 的队列里“偷”一半 G 过来。
  4. 执行:找到 G 后,调用 execute(g, inheritTime)

上下文切换

Go 如何从“调度逻辑”切换到“用户代码”? 根据架构不同,go 提供了不同的汇编源码,以 asm_amd64.s 为例

gogo 函数

  • proc.goexecute() 函数最后,会调用 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() 进行下一轮调度。



参考

GMP 原理与调度

L-Pig
L-Pig
© 2025 by L-Pig 本文基于 CC BY-NC-SA 4.0 许可 CC 协议 必须注明创作者 仅允许将作品用于非商业用途 改编作品必须遵循相同条款进行共享 最后更新:2026/1/8