一文带你彻底弄懂GMP调度模型!

42 阅读14分钟

数据结构

我们知道GMP模型是Go语言调度器的核心,而GMP模型主要由下面三部分组成:

  1. G - 表示Goroutine,有两种,一种是特殊的g0,一种是用户通过go func(){}创建的g,可以将其理解成一个个任务
  2. M - 表示操作系统中的线程,它由操作系统的调度器调度和管理
  3. P - 表示处理器,是存储g的容器,负责调度和管理自己存储的g

G

Goroutine是Go语言调度器调度的基本单位,地位和操作系统中的线程差不多,但是它占用空间更小,也降低了上下文切换的开销。

Goroutine只存在于Go语言的运行时,它是Go语言在用户态提供的线程,作为一种粒度更细的资源调度单元,使用得当可以在高并发的场景下更高效的利用机器的CPU

Goroutine在运行时使用runtime.g表示,接下来介绍其中的一些字段:

  • stack (stack):描述了当前Goroutine的栈内存范围[stack.Io,stack.hi)
  • stackguard0 (uintptr):用于调度器抢占式调度
  • preempt (bool):抢占信号
  • preemptStop (bool):抢占时将状态修改成_Gpreempted
  • preemptShrink (bool):在同步安全点收缩栈
  • _panic (*_panic):最内侧的panic结构体
  • _defer (*_defer):最内侧的延迟函数结构体
  • m (*m):当前Goroutine占用的线程,可能为空
  • atomicstatus (uint32):Goroutine的状态
  • sched (gobuf):存储Goroutine的调度相关的数据

对于sched字段,我们额外介绍其类型runtime.gobuf包含哪些内容:

  • sp (uintptr):栈指针
  • pc (uintptr):程序计数器
  • g (guintptr):持有runtime.gobuf的Goroutine
  • ret (sys.Uintreg):系统调用的返回值 这些内容会在调度器保存或者恢复上下文的时候用到,其中的栈指针和程序计数器会用来存储或者恢复寄存器中的值,改变程序即将执行的代码。举个例子:
func example(){
     fmt.Println("Hello")
    time.Sleep(time.Second)  // ← 当goroutine在这里阻塞时...
    fmt.Println("World")
}
// 调度器会保存:
// pc = time.Sleep调用后的下一条指令地址
// 即 fmt.Println("World") 的地址,这样就可以直接跳到该地址来执行剩下的代码

atomicstatus字段存储了当前Goroutine的状态。除了与GC相关的状态之外,Goroutine可能处于以下9种状态:

  • _Gidle:刚刚被分配并且还没有被初始化
  • _Grunnable:没有执行代码,没有栈的所有权,存储在运行队列中
  • _Grunning:可以执行代码,拥有栈的所有权,被赋予了内核线程M和处理器P
  • _Gsyscall:正在执行系统调用,拥有栈的所有权,没有执行用户代码,被赋予了内核线程但是不在运行队列中
  • _Gwaiting:由于运行时而被阻塞,没有执行用户代码,并且不在运行队列上,但是可能存在于Channel的等待队列上
  • _Gdead:没有被使用,没有执行代码,可能有分配的栈
  • _Gcopystack:栈正在被拷贝,没有执行代码,不在运行队列上
  • _Gpreempted:由于抢占而被阻塞,没有执行用户代码并且不在运行队列上,等待唤醒
  • _Gscan:GC正在扫描栈空间,没有执行代码,可以与其它状态共存

M

Go语言并发模型中的M是操作系统线程。

每一个线程对应一个运行时的runtime.m结构体

下面介绍一些runtime.m结构体中的一些字段:

  • g0 (*g):g0是持有调度栈的Goroutine
  • curg (*g):当前线程中正在运行的用户Goroutine
  • p (puintptr):正在运行代码的处理器p
  • nextp (puintptr):暂存的处理器
  • oldp (puintptr):执行系统调用之前使用该线程得处理器

除此之外,runtime.m还包含大量与线程状态,锁,调度,系统调用有关的字段,后面再进行介绍

P

处理器P能提供线程需要的上下文环境,也会负责调度线程上的等待队列,通过处理器P的调度,每一个内核线程都能够执行多个Goroutine,它能在Goroutine进行一些I/O操作时及时让出计算资源,提高线程的利用率

runtime.p是处理器的运行时表示,接下来介绍一些有关的字段:

  • m (muintptr):当前处理器绑定的m
  • runq ([256]guintptr):存储在当前P中的g队列
  • runqhead (uint32):队列首部索引
  • runqtail (uint32):队列尾部索引
  • runnext (guintptr):线程下一个需要执行的Goroutine
  • status (int32):当前处理器P的状态

对于status字段,有以下五种状态:

  • _Pidle:处理器没有运行用户代码或者调度器,被空闲队列或者改变其状态的结构持有,运行队列为空
  • _Prunning:被线程M持有,并且正在执行用户代码或者调度器
  • _Psyscall:没有执行用户代码,绑定的线程陷入系统调用,当前处理器被挂起。
  • _Pgcstop:被线程M持有,当前处理器由于垃圾回收被停止
  • _Pdead:当前处理器已经不被使用

总结

在这里我们介绍了Go语言调度器中常见的数据结构,下面就介绍Go语言调度器是怎么运作的

调度器启动

调度器启动时,会先执行调度器初始函数,该函数会将maxcount设置成10000(这就是一个Go语言程序能够创建的最大线程数,虽然最多可以创建10000个线程,但是可以同时运行的还是由GOMAXPROCS变量控制),然后函数会从环境变量GOMAXPROCS获取程序能够同时运行的最大处理器数,接着调用runtime.procresize函数

runtime.procresize函数中,会执行如下操作:

  • 如果全局变量allp切片中的处理器数量(默认等于cpu核心数)少于期望数量,会对切片进行扩容
  • 使用new创建新的处理器结构体并初始化,放入allp切片中
  • 通过指针将线程m0和处理器allp[0]绑定在一起
  • 释放不再使用的处理器结构(缩容时)
  • 通过截断改变全局变量allp的长度保证与期望处理器数量相等
  • 将除allp[0]之外的处理器p全部设置成_Pidle并加入到全局的空闲队列中

至此,调度器启动完毕,此时调度器启动了相应数量的处理器,等待用户创建新的Goroutine并为Goroutine调度处理器资源

创建Goroutine

当调用方使用go func(){}创建一个Goroutine时,go func(){}会被编译器翻译成runtime.newproc(size, fn, …),之后:

  1. newproc 通过 getg() 拿到当前 G,再取 g.m.p;(注意这里是先取M再取P,不一定有P但是一定有M,P可能会被剥离)
  2. 调用 newproc1 新建/复用 g,状态设为 _Grunnable;
  3. newproc1 把新 g 交给 runqput(pp, g, true);(pp表示调用方G是否有处理器P,第三个参数是next bool,true表示放在队列头部,优先执行,false表示放在队列尾部)
  4. 如果 pp == nil(没有),把新 g 放入全局队列
  5. 如果 pp != nil,本地队列未满 → 直接塞进 pp.runq, 本地队列满了 → runqputslow 把本地队列一半 + 新 g 批量移到全局队列
  6. 调度器后续通过 findrunnable 从本地或全局队列取出该 g,赋予 M 和 P,转为 _Grunning 开始执行。

绑定流程

  1. 操作系统线程M已经存在(可能是刚唤醒或者一直idle)、
  2. 该M先把自己绑定到某个P上
  3. 绑定成功后,进入调度循环

调度循环

调度器启动之后,Go 语言运行时会调用 runtime.mstart 以及 runtime.mstart1,前者会初始化 g0 的 stackguard0stackguard1 字段,后者会初始化线程并调用 runtime.schedule 进入调度循环

runtime.schedule函数会按以下顺序获取Goroutine:

  1. 每经历61次调度后,需要先处理一次全局队列grq(globrunqget--加锁),避免产生饥饿
  2. 从处理器本地的运行队列中查找待执行的 Goroutine
  3. 如果前两种方法都没有找到 Goroutine,会通过 runtime.findrunnable 进行阻塞地查找 Goroutine;

runtime.findrunnable会按照以下顺序获得Goroutine:

  1. 从本地运行队列、全局运行队列中查找;(防止并发漏查)
  2. 尝试获取io就绪的g(netpoll--非阻塞模式)
  3. 尝试从其它p的lrq窃取g(steal work)
  4. 若没找到g将p置为idle状态,添加到schedt pidle队列(动态缩容)
  5. 确保留守一个m,监听处理io就绪的g(netpoll--阻塞模式)
  6. 若m仍然无事可做(有其它m在监听io事件),则将其添加到schedt midle队列(动态缩容)
  7. 暂停m(回收资源)

总而言之,当前函数一定会返回一个可执行的Goroutine,如果当前不存在就会阻塞等待

执行Goroutine

获取到Gorouetine后,由runtime.execute执行获取的Goroutine,做好准备工作后,它会通过runtime.gogo将Groutine调度到当前线程上。然后经过一系列操作执行该Goroutine,执行完之后会将Goroutine转换回_Ghead状态, 清理其中的字段,移除Goroutine和线程的关联,并重新加入 处理器的Goroutine空闲列表gFree

在最后会重新调用runtime.schedule触发新一轮的Goroutine调度,Go语言中的运行时调度循环会从runtime.schedule开始,最后又回到runtime.schedule,我们可以认为调度循环永远都不会返回

触发调度

在这里重点介绍运行时触发调度的几个路径:

  • 主动挂起 - runtime.gopark->runtime.park_m
  • 系统调用 - runtime.exitsyscall->runtime.exitsyscall0
  • 系统监控 - runtime.sysmon->runtime.retake->runtime.preemptone

主动挂起

runtime.gopark是触发调度最常见的方法,该函数会将当前Goroutine暂停,被暂停的任务不会放回运行队列。之后通过runtime.mcall切换到g0的栈上调用runtime.park_m

runtime.park_m会将当前Goroutine的状态从_Grunning切换至_Gwaiting,调用runtime.dropg移除线程和Goroutine之间的关联,在这之后就可以调用runtime.schedule触发新一轮的调度

当Goroutine等待的特定条件满足后,运行时会调用runtime.goready把因为调用了runtime.gopark而陷入休眠的Goroutine唤醒,即将准备就绪的Goroutine的状态切换至_Grunnable并将其加入处理器的运行队列中,等待调度器的调用

一般在通道阻塞,定时等待,网络I/O阻塞,sync原语阻塞时会主动挂起

系统调用

在执行g的过程中,可能这个g需要使用到某些系统调用,而系统调用是m(thread)粒度的,在执行期间会导致整个m暂时不可用。而当g发起系统调用时,此时Go的操作是:

  1. 保存上下文
  2. g状态更新为syscall
  3. 解除p和m的绑定
  4. 将p设置为m.oldp(使得m syscall结束后,还有一次尝试复用p的机会)
  5. p状态更新为syscall

结束系统调用后,会进行如下步骤:

  1. 检查syscall期间,之前被剥离的p有没有和其它m结合,如果没有,直接复用p,继续执行g(fast path)
  2. 如果和别的m结合了,就通过mcall操作切换至g0,然后执行exitsyscall方法,尝试为当前m结合一个新的p,如果结合成功,则继续执行g,否则将g添加到grq后暂停m(slow path)

协作式调度--一种思想

协作式调度的核心思想是Goroutine在某些安全点主动调用调度函数,将控制权交还给调度器,从而实现了G的切换。主动挂起就是典型的协作式调度行为,是对该思想的具体实现。

抢占式调度

有了协作式调度,为什么还要有抢占式调度呢?因为如果一个Goroutine内部只是执行了一个超大的循环,但是却不调用函数,不阻塞,不访问channel,这样的Goroutine在协作式调度思想下,将永远不会让出CPU,导致其它Goroutine饥饿。所以为了解决这个问题,就有了抢占式调度的机制:运行时可以强制中断正在长时间运行的G

抢占式调度是怎么实现的

监控线程

在go程序运行时,会启动一个全局唯一的监控线程--sysmon thread,其负责定时执行监控工作,主要包括:

  1. 执行netpoll操作,唤醒io就绪的g
  2. 执行retake操作,对运行时间过长的g执行抢占操作
  3. 执行gcTrigger操作,探测是否需要发起新的gc轮次
工作原理
  1. 后台监控线程sysmon定期检查

    • sysmon是一个独立运行的M,每20ms左右唤醒一次
    • 它会扫描所有正在运行的P,检查其上的G是否运行时间过长,如果一个G已经连续运行超过10ms,就被认为是“长时间运行”,需要被抢占。sysmon会先设置抢占标志g.preempt=true,并发送信号通知对应M触发抢占
  2. 执行步骤

  • 用户 G 正在运行(使用自己的栈) → M 绑定 P,G 在 _Grunning 状态
  • sysmon 检测到该 G 运行超时(>10ms) → 调用 preemptM(m),向 m 发送 SIGURD(或其他平 台对应信号)
  • 信号中断当前执行流,进入信号处理函数
  • 在信号处理上下文中: * 获取当前 M 的 g0 * 保存当前用户 G 的 SP/PC 到其 g.sched 字段 * 设置 g.sched.pc = funcPC(gopreempt_m)// 下一步要执行的函数 * 修改当前上下文的 SP 指向 g0 的栈顶 * 修改当前上下文的 PC 指向 runtime.mcall
  • 信号返回后,CPU 开始在 g0 栈上执行 mcall(gopreempt_m)
  • mcall 切换完成,调用 gopreempt_m → goschedImpl → schedule() → 当前 G 被设为 _Grunnable,加入全局队列 → 调度器选择下一个 G 执行

总结

至此,GMP就大概结束了,但是我们发现,似乎所有调度路径,都要使用mcall()切换到g0栈才能执行,这是为什么?

  1. 栈空间不足风险:
  • 用户G的栈是动态增长的,可能只剩几百字节
  • schedule()函数本身调用链很深,需要大量栈空间
  • 可能导致栈溢出。 所以此时我们要切换到g0,因为g0的栈空间通常比用户G的栈空间大
  1. 破坏用户G的上下文
  • schedule()内部可能会修改寄存器,调用其它函数,甚至触发GC
  • 如果这些操作发生在用户G的上下文中,可能污染局部变量,破坏函数状态 所以我们使用g0 - 一个独立的执行环境,与用户代码完全隔离
  1. 避免状态混乱 “状态混乱”不是指“重复选 G”,而是指在用户栈上直接跑调度器代码会破坏运行时自身的内存/状态一致性;切到 g0 就保证了调度器总在一个干净的、无用户上下文的栈里执行,从而避免递归和错乱。
  2. 保证原子性与锁安全
  • schedule()需要操作全局资源:
    • sched全局结构
    • P的本地队列,全局队列
    • 抢占标志,GC状态等 这些操作通常需要加锁,如果在用户G中执行:
  • 加锁期间不能被抢占
  • 但用户G可能在任何地方被中断

所有调度路径必须通过 mcall 切换到 g0,是因为 g0 提供了一个独立、可靠、安全的执行环境,使得 runtime 能够在不干扰用户代码的前提下,安全地操作调度器内部数据结构,避免栈溢出、上下文污染和状态混乱。

“用户 G 可能在任何地方被中断”意思是: 如果在用户栈上跑调度器并拿锁,锁区间里随时可能被抢占,导致锁未解人就换走,下一次调度又进来拿同一把锁 —— 死锁或数据竞争; 切到 g0 后,调度器代码运行在抢占已关闭的干净上下文,才能安全地操作全局资源。

  1. 而且g0不怕上下文被污染: g0 是一个无状态、无历史、无调度入口的“一次性系统栈”,每次使用都是从新入口开始,所以它的上下文不会被污染,也不需要保留。