Go语言中的协程调度GMP

golang gmp

Posted by alovn on February 12, 2021

GMP

1
2
3
4
package main
func main() {
    println("hello world")
}

一个 helloworld 程序编译后成为一个二进制文件,执行时候可执行文件加载到内存中。对于地址虚拟空间中的代码段它并不是我们熟悉的main.main。不同平台下程序执行入口不同。在通过一系列检查与初始化等准备工作后,会以runtime.main为执行入口,创建main goroutine。main goroutine 执行起来以后才会调用我们编写的main.main。

Go语言中协程对应的数据结构是runtime.g,工作线程对应的数据结构是runtime.m。数据段上有几个重要的全局变量 g0,m0,allgs,allm,allp,sched:

  1. 全局变量g0就是主协程对应的g,
  2. 全局变量m0就是主线程对应的m。g0持有m0的指针,m0里也记录着g0的指针,而且一开始m0上执行的协程正是g0,m0和g0就这样联系了起来。
  3. 全局变量allgs记录所有的g。
  4. allm记录所有的m。
  5. allp记录所有的p。
  6. sched记录调度相关的内容,如空闲的m,空闲的p…

最初Go语言里的调度模型只有M和G,待执行的G排排坐,每个M来获取一个G时都要加锁。多个M分担多个G的执行任务,就会因为频繁加锁、解锁而发生等待,影响程序并发性能,所以在M和G之外又引入了P。P对应的数据结构是runtime.p,它有一个本地runq,这样只要把一个P关联到一个M,这个M就可以从P这里直接获取待执行的G,不用每次都和众多M从一个全局队列中争抢任务了。

虽然P有一个本地runq,但是依然有一个全局runq,它保存在全局变量sched中,这个全局变量代表着调度器,对应的数据结构是runtime.schedt,它记录着所有空闲的m、空闲的p等等许多和调度相关的内容,其中就有一个全局的runq。

如果P的本地队列已满,那么等待执行的G就会被放到这个全局队列里,而M会先从关联P执行的本地runq中获取待执行的G,没有的话再到调度器持有的全局队列领取一些任务。如果全局队列里也没有G了,就会去别的P那里分担一些G过来。同G和M一样,也有一个全局变量用于保存所有的P。

在程序初始化过程中会进行调度器初始化,这时会按照GOMAXPROCS这个环境变量决定创建多少个p,保存在全局变量allp中,并且把第一个P(allp[0]) 与 m0 关联起来。简单来说G、M、和P就是这样的合作关系。

在main goroutine创建之前,G、P、M的情况是这样的:main goroutine创建之后被加入到当前P的本地队列中,然后通过mstart函数开启调度循环,这个mstart函数是所有工作线程的入口,主要就是调用schedule函数,也就是执行调度循环。其实对于一个活跃的m而言不是在执行某各G就是在执行调度程序获取某个G。队列里只有main goroutin等待执行,所以m0切换到main goroutine,执行入口当然是runtime.main,它会做很多事情,包括创建监控线程、进行包初始化等等,其中也包括调用main.main,然后就可以输出hello world了。

在main.main返回之后runtime.main会调用exit()函数结束进程。

创建goroutine

1
2
3
4
5
6
7
8
9
10
package main

func hello() {
    println("hello world")
}

func main(){
    go hello()
    //time.Sleep(3*time.Second)
}

如果在main.main中不直接输出,而是调用一个goroutine来输出,那么到main.main被调用执行时就会创建一个新的goroutine,我们把它标记为『hello goroutine』。通过go关键字创建协程会被编译器转换为newproc函数调用,main goroutine同样也是newproc函数创建的。

创建goroutine时我们的代码只负责指定入口、参数,而newproc会给goroutine构造一个栈帧,目的是让协程任务结束后返回到goexit函数中,进行协程资源回收处理等工作。

假如通过设置GOMAXPROCS只创建一个P,这个示例代码中新创建的hello goroutine会被添加到本地队列runq中,然后main.main就结束返回了,再然后exit函数被调用进程就结束了,所以hello goroutine并没有被执行。问题就在于main.main返回后exit函数就会被调用,直接把进程给结束掉,没给hello goroutine空出调度执行的时间,所以要想让hello goruntine执行就要在main.main返回之前拖延下时间。

可以使用time.Sleep、等待一个chanel或者是WaitGroup,反正只要main.main不马上返回,hello groutine就有时间执行了。如果使用time.Sleep函数,实际上会调用gopark函数把当前协程的状态从_Grunning修改为_Gwaiting。然后main goroutine不会回到当前P的runq中,而是在timer中等待,继而调用schedule函数进行调度,hello goroutine得以执行。等到sleep的时间到达后timer会把main groutine重新设置为_Grunable状态,放回到P的runq中,然后main.main结束,exit被调用进程退出。

如果创建了多个P,hello goroutine创建之后虽然默认会添加到当前P的本地队列里,但是在有空闲P的情况下,就可以启动新的线程关联到这个空闲的P,并把goroutine放到它的本地队列里了。