一、Golang调度器的由来
-
单线程时代的问题?
- 单一线程执行,计算机只能一个任务一个任务的处理
- 当一个线程阻塞时,会带来CPU时间的浪费
-
多线程、多进程的问题?
- 系统的设计变得复,比如说会遇上同步竞争问题(如 锁、竞争资源冲突),当线程/进程数量越多,系统进行切换的成本就越大,切换成本体现在资源加载上
- 多线程/进程会造成高内存占用以及高CPU调度消耗
-
因此创建了协程(co-routine),但是也有以下问题:
- N:1模型:无法利用多个CPU,当一个协程阻塞时会浪费系统调度
- 1:1模型:同样遇到了协程切换过程中的资源浪费问题
- M:N模型:能够利用多核,但是高度依赖调度算法
-
Goroutine的出现
优点:内存占用低,几kb可以进行大量开辟,并且可以进行灵活切换
缺点:早期的Goroutine在创建、销毁、调度G都需要每个M获取锁,这回形成激烈的锁竞争。M转移G会造成延迟和额外的系统负载。并且系统在频繁的线程阻塞和取消阻塞操作会增加系统的开销。
-
原始的MG模型
在Go语言早期,协程调度器模型并不是G-M-P,而是G-M模型。整个调度器就只有一个全局的等待队列G,同时所有的M都从全局的队列中获取协程G来执行。该模型最初应用于go1.1版本,后来被现在的G-M-P模型给替代,即加入了协程的本地队列。增加P的原因如下:
- 不同的M从全局带执行协程队列中获取要执行的协程时,需要加锁。锁的粒度越大,就限制系统并发能力的改进。
- 如果没有本地队列,当线程执行IO密集型操作时,M会阻塞IO操作,并且相应的G无法执行(GMP可以将G交给其他M执行),因此GM模型在处理IO密集型任务时性能较低。
二、GMP模型的设计思想
-
GMP模型的简介:
- G:代表协程
- M:操作系统下内核态的线程。在Go中能支持的最大线程数量是10000个,但一般情况下不会创建这么多线程。
- P:处理器,可以把它理解为这时候一个等待被分配给M的协程队列。P的数量一般通过参数
runtime.GOMAXPROCS
设定。
-
调度器的设计策略
-
复用线路:work stealing机制、hand off机制。
work stealing机制:
- 每个处理器(P)都有一个本地任务队列,它首先尝试从自己的队列中获取任务执行。
- 如果本地队列为空,处理器会尝试从其他处理器的队列中随机选择一个队列并尝试窃取任务。
- 如果成功窃取任务,它将执行这个任务,并继续工作。
- 先其他P的本地队列获取,再全局队列获取,因为全局队列里面的G是上锁的
hand off机制:
当M1中的一个协程G阻塞时,P以及P的本地队列会与M1进行分离,系统会创建/唤醒一个thread也就是M3,这个分离的P会与M3进行绑定。M1进入休眠状态,后续M1上的G如果需要继续执行会绑定其他的P,M1j进入休眠或者销毁状态
-
利用并行机制:通过GOMAXPROCS限定P的个数,假如P=线程数/2,哪还有一半的CPU可以给别的线程使用
-
抢占:在co-routine时代,c执行的是主动释放,也就是c自己释放cpu,CPU才能去与其他的c进行绑定。在goroutine时代每个G与CPU的绑定时间是10ms,超过10ms,新的G会抢占CPU
-
全局G队列:先从其他P的本地队列上偷,其他的P上没得再去全局上进行解锁与加锁取G
-
-
"go func()"经历的过程:
1、我们通过
go func()
来创建一个goroutine;
2、有两个存储G的队列,一个是局部调度器P的本地队列、一个是全局G队列。新创建的G会先保存在P的本地队列中,如果P的本地队列已经满了就会保存在全局的队列列中;
3、G只能运行在M中,一个M必须持有一个P,M与P是1:1的关系。M会从P的本地队列弹出一个可执行状态的G来执行,如果P的本地队列为空,就会想其他的MP组合偷取一个可执行的G来执行;
4、一个M调度G执行的过程是一个循环机制;
5、当M执行某一个G时候如果发生了syscall或则其余阻塞操作,M会阻塞,如果当前有一些G在执行,runtime会把这个线程M从P中摘除(detach),然后再创建一个新的操作系统的线程(如果有空闲的线程可用就复用空闲线程)来服务于这个P;
6、当M系统调用结束时候,这个G会尝试获取一个空闲的P执行,并放入到这个P的本地队列列。如果获取不到P,那么这个线程M变成休眠状态, 加入到空闲线程中,然后这个G会被放入全局队列中。 -
调度器的生命周期:
M0:启动程序后的编号为0的主线程,保存至在全局变量runtime.m0中,不需要在heap上分配,负责初始化操作和启动第一个G,启动第一个G后,m0就和其他的M一样了
G0:每次启动一个M,都会有第一个创建的goroutine,就是G0,G0仅用于负责调度G,G0不指向任何可执行的函数,每个M都会有一个自己的G0,在调度或系统调用时会使用M进行切换到G0,来调度,M0的G0会放在全局空间
-
可视化GMP编程:
- 通过go tool trace工具打开trace⽂文件
package main import ( "fmt" "os" "runtime/trace" ) // 基本trace的编程过程 // 1.创建文件 // 2.启动 // 3.停止 func main() { //1.创建一个trace文件 f, err := os.Create("trace.out") if err != nil { panic(err) } defer f.Close() //2.启动trace err = trace.Start(f) if err != nil { panic(err) } //正常要测试的内容 fmt.Println("hello GMP") //3.停止trace trace.Stop() }
通过
go tool trace
工具来打开trace文件$go tool trace trace.out
-
GMP终端GODEBUG调试:
在Linux下使用:GODEBUG=schedtrace=1000 ./trace 但是在win下无法进行操作
三、Go调度器GMP调度场景的全过程分析
-
G1创建G2:当一个G1创建了新的G2,这个G2会进入G1所在的本地队列中
-
G1执行完毕:当M1执行完G1时,M1优先从本地队列中获取G,并且切换G时,通过G0来调度
-
G2开辟过多G,先把创建的G放入P的本地队列,再放入全局队列中,放入的过程为,将P切一半,把前一半打乱并放到全局队列中,再把G满后的哪个生成的G一起放在全局队列中,将P本地对列内的G往前移动,当再创建G时,由于本地队列未满,所以将G新创建的G放入P的本地队列中
- 唤醒正在休眠的M:从休眠队列中拿出M,M后一位向前移动。再找到P构成一个自旋线程(没有G但处于运行状态的线程,不断寻找G),自旋线程是为了不断寻找G
-
被唤醒的M从全局队列中取批量的G,从全局对列取的个数
n=min(len(全局队列数量)/GOMAXPROCS+1,len(本地队列数量/2))
当全局队列数量个数为1时怎么处理? -
M2从M1中偷取G,从其他队列中偷
-
自旋线程的最大限制:自旋线程 + 执行线程 ≤ GOMAXPROCS
-
G发生系统调用/阻塞,自旋线程抢占的是G,不能抢占P
-
G发生系统调用/非阻塞,当G的原配已经被抢占了,并且空闲P为空,G将返回全局队列