GMP协程调度实现原理 | 青训营笔记

331 阅读6分钟

GMP协程调度实现原理

GMP模型

image-20230611121040217.png

G

Goroutine协程,保存goroutine的状态信息。简单来说,就是保存运行goroutine所需要的上下文信息。,以便轮到本goroutine执行时,CPU能够获取所需要的依赖指以及从哪一条指令处开始执行。

G分为三类:

  • 主协程,用来执行用户main函数的协程
  • 主协程创建的协程,也是P调度的主要成员
  • G0,每个M都有一个G0协程,他是runtime的一部分,是M创建的第一个G,与该M唯一绑定,被用来调度其它G。

M

Machine,代表实际工作的执行者,对应到操作系统级别的线程。所有的G都需要调度到M上才能运行。

M分为两类:

  • 普通M,用来与P绑定执行G中任务
  • M0,M0是启动程序后的编号为0的主线程。M0负责执行初始化操作核启动第一个G,之后就和其它M一样了

P

Processor,调度逻辑处理器,同时也是Go中代表资源的分配主体,默认为机器核数,可以通过GOMAXPROCS环境变量调整。其为M的执行提供“上下文”,保存执行G时所需要的一些资源。

调度器的设计策略

复用线程,避免频繁的创建、销毁线程,而是对线程的复用。

工作窃取

当一个P发现自己的LRQ(Local Runnable Queue)已经没有可执行的G时,尝试从其它线程绑定的P偷取G,而不是销毁线程。如果从其它P偷不到,那么从全局队列中获取。

调度时机

  • 主动调度

    协程通过runtime.Goshed方法主动让渡自己的执行权力,之后这个协程会被放到全局队列中,等待后续被执行

  • 被动调度

    协程在休眠,系统调用、执行垃圾会少而导致阻塞或暂停时,协程LRQ里的其它G会被调走给其它P,而占用的这个G则单独和该M进行绑定。该G在后续还需执行时,分给其它P。

  • 抢占

    一个G最多占用CPU 10ms,防止其它G被饿死

goroutine和线程的区别

内存占用

创建一个goroutine的栈内存消耗为 2 KB,实际运行中,如果栈空间不够用,会自动进行扩容。

创建一个thread则需要消耗 1 MB 栈内存,而且还需要一个被称为“a guard page”的区域用于和其它 thread 的栈空间进行隔离。

这意味着对于一个用Go构建的 HTTP Server 而言,对于到来的每个请求,创建一个 goroutine 用来处理是非常轻松的一件事。而如果使用线程作为并发原语的语言构建的服务,每个请求对应一个线程太浪费资源,容易 OOM

创建和销毁

Thread创建和销毁都会有巨大的消耗,因为要和操作系统打交道,是内核级的,通常解决的办法就是线程池。

goroutine是由Go runtime负责管理的,创建和销毁的消耗非常小,是用户级。

切换

goroutine切换只需要保存三个寄存器:Program Counter,Stack Pointer and BP

约为200ns,相当于2400到3600条指令。

而线程切换需要1000-1500ns

调度发生地点

Go中协程的调度发生在runtime,属于用户态,不涉及与内核态的切换;一个协程可以被切换到多个线程执行。

调度策略

线程调度大部分是抢占式调度,操作系统通过发出中断信号强制线程切换上下文;Go的协程基本是主动和被动式调度,调度时机可预期。

M:N模型

image-20230611133005779.png

Runtime会在程序启动时,创建M个线程,之后创建的N个goroutine都会依附在这M个线程上执行,这就是M:N模型。

Go调度器场景过程解析

场景1:G1创建G2

image-20230611142226499.png

为了局部性,G2优先加入到P1的本地队列

场景2:G1执行完毕

image-20230611143312976.png G1完成后,M上运行的goroutine切换为G0,G0负责调度时协程的切换,从P的LRQ中取G2,从G0切换回G2,并开始运行G2,实现了线程M1的复用

场景3:G2开辟过多的G

image-20230611143713930.png G2创建6个G,那么前4个G会被保存在本地的LRQ中,而在创建超出4个的新G时,会执行负载均衡策略,将前一半的G与新G打乱顺序插入到全局队列中。

image-20230611144005253.png

场景4:创建G时唤醒正在休眠的M

image-20230611144210665.png 在创建G时,运行的G会尝试唤醒其它空闲的P和M组合去执行。

假定G2唤醒了M2,M2绑定了P2,并运行G0,但是P2的LRQ没有G,那么M2就会自旋(没有G但为运行状态的线程,不断寻找G)。如果M醒来发现没有P能和它组合,那么它就不能自旋。

系统中最多有GOMAXPROCS个自旋或正在工作的线程,多余的会让它们休眠。

场景5:被唤醒的M2(自旋状态)努力找工作

image-20230611145048495.png M2尝试从全局队列(简称“GQ”)取一批G放到LRQ中,其取得数量符合以下公式:

n=min(len(GQ)/GOMAXPROCS+1,cap(LQ)/2)n = min(len(GQ) / GOMAXPROCS+1,cap(LQ)/2)

如上图,从GQ取得G3后,从G0切换到G3并开始运行。

如果GQ中没有G能获取,M2仍旧会积极找工作,它会从别人那里投一些G过来(一半偷另一个M得一半)

image-20230611145808008.png

场景6:G发生系统调用,阻塞

image-20230611150608810.png

当前M3和M4为自旋线程,还有M5和M6为空闲正在休眠的线程。G8创建了G9,并加入到LRQ中,此时G8进行了阻塞的系统调用。于是,M2和P2立即进行解绑,P2会进行如下判断:

  1. P2本地有G,全局队列有G或有空闲正在休眠的M,P2会立马唤醒一个M与它绑定
  2. 否则,P2加入到空闲P列表,等待M来获取可用的P

而M2在等待G8结束后,会与G8解绑,并寻找新的P进入自旋找工作状态,如果无法找到P,就会进入休眠,长时间的休眠会被GC回收。

而G8则会加入到全局队列。

场景7:G发生系统调用,非阻塞

image-20230611151316484.png M2与P2会解绑,但是M2会记住P2,然后G8和M2进入系统调用状态,该状态结束后,M2会尝试获取P2,如果无法获取,则尝试获取空闲的P进入自旋找工作状态。如果还是无法获取,进入休眠状态。同时G8会标记为可运行状态并加入到全局队列。

参考文章

  1. GO GMP协程调度实现原理 5w字长文史上最全
  2. [Go三关-典藏版]Golang调度器GPM原理与调度全分析