GMP协程调度实现原理
GMP模型
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模型
Runtime会在程序启动时,创建M个线程,之后创建的N个goroutine都会依附在这M个线程上执行,这就是M:N模型。
Go调度器场景过程解析
场景1:G1创建G2
为了局部性,G2优先加入到P1的本地队列
场景2:G1执行完毕
G1完成后,M上运行的goroutine切换为G0,G0负责调度时协程的切换,从P的LRQ中取G2,从G0切换回G2,并开始运行G2,实现了线程M1的复用
场景3:G2开辟过多的G
G2创建6个G,那么前4个G会被保存在本地的LRQ中,而在创建超出4个的新G时,会执行负载均衡策略,将前一半的G与新G打乱顺序插入到全局队列中。
场景4:创建G时唤醒正在休眠的M
在创建G时,运行的G会尝试唤醒其它空闲的P和M组合去执行。
假定G2唤醒了M2,M2绑定了P2,并运行G0,但是P2的LRQ没有G,那么M2就会自旋(没有G但为运行状态的线程,不断寻找G)。如果M醒来发现没有P能和它组合,那么它就不能自旋。
系统中最多有GOMAXPROCS个自旋或正在工作的线程,多余的会让它们休眠。
场景5:被唤醒的M2(自旋状态)努力找工作
M2尝试从全局队列(简称“GQ”)取一批G放到LRQ中,其取得数量符合以下公式:
如上图,从GQ取得G3后,从G0切换到G3并开始运行。
如果GQ中没有G能获取,M2仍旧会积极找工作,它会从别人那里投一些G过来(一半偷另一个M得一半)
场景6:G发生系统调用,阻塞
当前M3和M4为自旋线程,还有M5和M6为空闲正在休眠的线程。G8创建了G9,并加入到LRQ中,此时G8进行了阻塞的系统调用。于是,M2和P2立即进行解绑,P2会进行如下判断:
- P2本地有G,全局队列有G或有空闲正在休眠的M,P2会立马唤醒一个M与它绑定
- 否则,P2加入到空闲P列表,等待M来获取可用的P
而M2在等待G8结束后,会与G8解绑,并寻找新的P进入自旋找工作状态,如果无法找到P,就会进入休眠,长时间的休眠会被GC回收。
而G8则会加入到全局队列。
场景7:G发生系统调用,非阻塞
M2与P2会解绑,但是M2会记住P2,然后G8和M2进入系统调用状态,该状态结束后,M2会尝试获取P2,如果无法获取,则尝试获取空闲的P进入自旋找工作状态。如果还是无法获取,进入休眠状态。同时G8会标记为可运行状态并加入到全局队列。