跳转至

GMP 调度模型

约 2579 个字 14 行代码 1 张图片 预计阅读时间 13 分钟

1. Go语言的GMP模型是什么?

GMP是Go运行时的核心调度模型

GMP含义:G(goroutine)是用户任务;M(machine)是绑定在操作系统线程上的执行实体;P(processor)是**逻辑处理器**,持有本地运行队列(LRQ)等调度资源,并与某个 M 绑定。真正决定“下一个运行哪个 G”的是调度器逻辑(如 schedule / findRunnable),通常不把整个调度器等同于单个 P。

调度逻辑是这样的,M必须绑定P才能执行G。每个P维护一个自己的本地G队列(长度256),M从P的本地队列取G执行。当本地队列空时,M会按优先级从**全局队列、网络轮询器、其他P队列**中窃取goroutine,这是work-stealing机制。

就是这个模型让Go能在少量线程上调度海量goroutine,是Go高并发的基础。

2. 什么是Go scheduler

Go scheduler就是Go运行时的**协程调度器**,负责在系统线程上调度执行goroutine。它是 Go runtime 的一部分,它内嵌在 Go 程序里,和 Go 程序一起运行。它的主要工作是决定哪个goroutine在哪个线程上运行,以及何时进行上下文切换。scheduler的核心是schedule()函数,它在无限循环中寻找可运行的goroutine。当找到后通过execute()函数切换到goroutine执行,goroutine主动让出或被抢占时再回到调度循环。

3. Go语言在进行goroutine调度的时候,调度策略是怎样的?

Go语言采用的是抢占式调度策略。Go 会启动一个线程,一直运行着"sysmon"函数,sysmon 运行在 M上,且不需要P。当 sysmon 发现 M 已运行同一个 G(Goroutine)10ms 以上时,它会将该 G 的内部参数 preempt 设置为 true,表示需要被抢占,让出CPU了。只是在Go 1.14之前和Go 1.14之后有所不同

Go 1.14之前:调度策略是"协作式"抢占调度,这种调度方式主要是通过函数调用来实现的,在编译期,编译器会在几乎所有的函数调用的入口处,插入一小段检查代码。这段代码会检查当前goroutine是否已经被标记为需要被抢占。如果是,当 G 进行函数调用时,G 会检查自己的 preempt 标志,如果它为 true,则它将自己与 M 分离并推入goroutine的全局队列,抢占完成。但这种模式有个明显的缺陷:如果一个goroutine执行了一个不包含任何函数调用的**超大循环**,那么调度器的"抢占"标记就永远得不到检查,这个goroutine就会一直霸占着M,导致同一个P队列里的其他G全都没机会执行,造成**调度延迟**。

Go 1.14之后:调度策略**基于信号的异步抢占**机制,sysmon 会检测到运行了 10ms 以上的 G(goroutine)。然后,sysmon 向运行 G 的 M发送信号(SIGURG)。Go 的信号处理程序会调用M上的一个叫作 gsignal 的 goroutine 来处理该信号,并使其检查该信号。gsignal 看到抢占信号,停止正在运行的 G。

4. 发生调度的时机有哪些?

  • 等待读取或写入未缓冲的通道

  • 由于 time.Sleep() 而等待

  • 等待互斥量释放

  • 发生系统调用

5. M寻找可运行G的过程是怎样的?

M会优先检查本地队列(LRQ)

从当前P的LRQ里runqget一个G。(无锁CAS),如果本地队列没有可运行G,再次检查全局队列(GRQ)**去全局队列里globrunqget找。(需要加锁);如果还没有,就**检查网络轮询器(netpoll),就去netpoll里看看有没有因为网络IO就绪的G。(非阻塞模式),依然没有获取到可运行G,则会**从别的P偷(steal work)**,这个偷的过程是随机找一个别的P,从它的LRQ里偷一半的G过来。

6. GMP能不能去掉P层?会怎么样?

GMP中的P层理论上可以去掉,但会带来严重的性能问题。

掉P的后果

如果直接变成GM模型,所有M都需要从**全局队列**中获取goroutine,这就需要全局锁保护。在高并发场景下,大量M争抢同一把锁会造成严重的**锁竞争**,CPU大部分时间都浪费在等锁上,调度效率急剧下降。

P层的价值

P的存在实现了**无锁的本地调度**。每个P维护独立的本地队列,M绑定P后可以直接从本地队列取G执行,大部分情况下都不需要全局锁。只有本地队列空了才去偷取,这大大减少了锁竞争。

7. P和M在什么时候会被创建?

P的创建时机

P在调度器初始化时**一次性创建**。在schedinit()函数中会调用procresize(),根据GOMAXPROCS值创建对应数量的P对象,存储在全局的allp数组中。之后P的数量基本固定,只有在调用runtime.GOMAXPROCS()动态调整时才会重新分配P。

M的创建时机

M采用**按需创建**策略。初始只有m0存在,当出现以下情况时会创建新的M:

  • 所有现有M都在执行阻塞的系统调用,但还有可运行的goroutine需要执行

  • 通过startm()函数发现没有空闲M可以绑定P执行goroutine

  • M的数量受GOMAXTHREADS限制,默认10000个

创建流程:新M通过newm()函数创建,它会调用newosproc()创建新的系统线程,并为这个M分配独立的g0。创建完成后,新M会进入mstart()开始调度循环。

8. m0是什么,有什么用

m0是在Go启动时创建的第一个M,m0对应程序启动时的主系统线程,它在Go程序的整个生命周期中都存在。与其他通过runtime.newm()动态创建的M不同,m0是在程序初始化阶段静态分配的,有专门的全局变量存储。

m0主要负责执行Go程序的**启动流程**,包括调度器初始化、内存管理器初始化、垃圾回收器设置等。它会创建并运行第一个用户goroutine来执行main.main函数。在程序运行期间,m0也参与正常的goroutine调度,和其他M没有本质区别。m0在程序退出时还负责处理清理工作,比如等待其他goroutine结束、执行defer函数等。

9. g0是一个怎样的协程,有什么用?

g0是一个特殊的goroutine,不是普通的用户协程,而是**调度协程**,每个M都有自己的g0。它使用系统线程的原始栈空间,而不是像普通goroutine那样使用可增长的分段栈。g0的栈大小通常是8KB,比普通goroutine的2KB初始栈要大。

核心作用:g0专门负责**执行调度逻辑**,包括goroutine的创建、销毁、调度决策等。当M需要进行调度时,会从当前运行的用户goroutine切换到g0上执行schedule()函数。g0还负责处理垃圾回收、栈扫描、信号处理等运行时操作。

运行机制:正常情况下M在用户goroutine上运行用户代码,当发生调度事件时(如goroutine阻塞、抢占、系统调用返回等),M会切换到g0执行调度器代码,选出下一个要运行的goroutine后再切换过去。

为什么需要g0:因为调度器代码不能在普通goroutine的栈上执行,那样会有栈空间冲突和递归调度的问题。g0提供了一个独立的执行环境,确保调度器能安全稳定地工作。

10. g0栈和用户栈是如何进行切换的?

g0和用户goroutine之间的栈切换,本质是**SP寄存器和栈指针的切换。**当用户goroutine需要调度时,通过mcall()函数切换到g0。这个过程会保存当前用户goroutine的PC、SP等寄存器到其gobuf中,然后将SP指向g0的栈,PC指向传入的调度函数。调度完成后,通过gogo()函数从g0切换回用户goroutine,恢复其保存的寄存器状态。

切换逻辑在汇编文件中实现,比如runtime·mcallruntime·gogo。这些函数直接操作CPU寄存器,确保切换的原子性和高效性。切换过程中会更新g.sched字段记录goroutine状态。

分析

goroutine的结构如下:

Go
structG
{
    uintptr    stackguard;    // 分段栈的可用空间下界
    uintptr    stackbase;     // 分段栈的栈基址
    Gobuf    sched;           //协程切换时,利用sched域来保存上下文
    uintptr    stack0; 
    FuncVal*    fnstart;        // goroutine运行的函数void*    param;        // 用于传递参数,睡眠时其它goroutine设置param,唤醒时此goroutine可以获取
    int16    status;          // 状态    Gidle,Grunnable,Grunning,Gsyscall,Gwaiting,Gdead
    int64    goid;            // goroutine的id号
    G*    schedlink;
    M*    m;                  // for debuggers, but offset not hard-coded
    M*    lockedm;            // G被锁定只能在这个m上运行
    uintptr    gopc;          // 创建这个goroutine的go表达式的pc...
 };

11. 原理补充:与 runtime 行为对齐

下列内容便于与源码阅读对照,细节以当前 Go 版本为准。

P 与队列

  • 每个 P 持有 LRQ(本地可运行 G 队列,长度常数为 256)以及可选的 runnext:用于优先运行刚被唤醒或刚创建的 G,减少队列操作。
  • GRQ(全局队列)由全局 sched 管理,访问需加锁;新 G 优先放入当前 P 的 LRQ,runnext 或 LRQ 满时可能转入 GRQ(与 runqputslow 等逻辑相关)。

找 G 的优先级(findRunnable 思路)

实现会随版本微调,典型逻辑包含:

  1. 防饥饿:在满足周期条件时(历史上与 schedtick 取模相关,例如 每 61 次)先尝试从 GRQ 取 G,避免全局队列长期得不到执行。
  2. 当前 P 的 LRQ / runnext(本地优先,开销低)。
  3. 若本地为空,再尝试 GRQ(若仍有积压)。
  4. netpoll:拉取因网络 I/O 就绪的 G。
  5. work-stealing:从其他 P 的 LRQ 窃取任务。
  6. 若仍无工作,可能进入 P/M 闲置、阻塞式 netpollstopm 等路径(见 proc.go)。

新建 G(go func

newproc 最终在系统栈上创建 G,经 runqput 放入当前 P 的 runnext 或 LRQ;LRQ 满时可能批量搬到 GRQ,并视情况 wakep 唤醒闲置 P。

评论