目标:掌握Golang协程调度器的原理,为什么Go的协程调度是很快的?
文章目录
Golang调度器的由来
背景
- 单进程的两个问题
- 单一执行流程、计算机只能一个任务一个任务处理
- 进程阻塞带来的CPU浪费时间
-
多线程问题
- 切换成本,进程/线程的数量越多,切换成本就越大,也就越浪费。看起来你的CPU利用率达到了100%,其实可能程序就只占用了60%,而剩下的40%都是处于切换中。
- 多线程随着同步竞争(如锁、竞争资源冲突等),开发设计变得越来越复杂。
- 假设将每一个任务放在一个线程中执行,会造成高度消耗CPU,高内存占用(进程占用内存 虚拟内存4GB、线程占用内存约4MB)【壁垒】
调度器的由来
为了更好的解决上面所面临的问题,开发者做出了以下的一些改变。
-
将一个线程划分成两个空间,一个面向用户一个面向系统
-
CPU对用户空间是无感的
-
形成了3种对应关系
N:1无法利用多个CPU,1:1 跟多线程/多进程模型无异,M:N能够利用多核但比较依赖于协程调度器的设计。【基于这样的分析,最终将并发问题转换到了调度器的设计】
-
Golang对协程的优化处理
- Golang中将协程(co-routine)改叫为Goroutine,同时一个Goroutine内存只会占用几kb,意味着可以大量生产。同时Goroutine灵活调度,可常切换。
Golang对早期调度器的处理
G-goroutine、M-thread。早期的调度器包含一个基本全局的Goroutine队列和比较传统的轮询利用多个Thread去调度。
这样的老调度器主要有以下的问题:
- 创建、销毁、调度G都需要每个M获取锁,这就形成了激烈的锁竞争。
- M转移G会造成延迟和额外的系统负载。
- 系统调用(CPU在M之间的切换)导致频繁的线程阻塞和取消阻塞操作增加了系统开销。
goroutine调度器的GMP模型的设计思想
GMP模型的简介
-
GMP的含义
- G表示goroutine协程
- P表示processor处理器
- M表示thread线程
-
整体架构⭐️⭐️⭐️
- 全局队列:存放等待运行的G;
- 本地队列:有数量限制(不超过256G),新创建的G优先放在本地队列中,如果本地队列满了就放到全局队列当中;
- P列表:程序启动的时间就创建,最多有GOMAXPROCS(可配置)
- M列表:当前操作系统分配到当前Go程序的内核线程数
- P和M的数量:
- P的数量:可以通过环境变量GOMAXPROCS或者在程序中通过runtime.GOMAXPROCS()来设置【注意这里是并行的数量,而不是宏观层面的并发】
- M的数量:Go语言本身是限定M的最大量是10000。可以通过runtime/debug包中的SetMaxThread函数来设置,但是一般不会来设置这个数量。一般来说,如果有一个M阻塞,会创建一个新的M,如果M空闲,那么就会回收或者睡眠。
调度器的设计策略
复用线程
避免频繁地创建、销毁线程,而是对外线程的复用
-
Work Stealing机制
当本线程无可运行的G时,尝试从其他线程绑定的P偷取G,而不是销毁线程。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-lAg0ffpB-1678321689096)(#pic_center)]
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-WoMh4TSd-1678321746533)(#pic_center)]
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-kOI116oP-1678321762724)(#pic_center)] -
Hand off机制
当本线程因为G进行系统调用阻塞时,线程释放绑定的P,把P转移到其他空闲的线程执行
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-HicNhhrl-1678321785872)(#pic_center)]
利用并行
通过GOMAXPROCS限定P的个数=CPU核数/2
抢占
在co-groutine中要等待一个协程主动让出CPU才执行下一个协程,在Go中,一个goroutine最多占用CPU 10ms,防止其他的goroutine被饿死。
全局G队列
线程对应的本地队列空闲时候他会优先从其他线程的本地队列中偷取G,如果其他的本地队列中也没有G,那么这个时候就会从全局队列中获取G【当然从全局队列取的时候需要加锁】
"Go func(){}()"经历了什么过程⭐️⭐️⭐️
具体来说主要有以下流程(建议加上后面的M0和G0)
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执行的过程是一个循环机制;(调度—>执行func—>调度)
5、当M执行G的时候如果发生了syscall或者其余阻塞操作,M会阻塞,如果当前有一些G在执行,runtime会把这个线程从P中摘除(detach),然后再创建一个新的操作系统的线程(如果有空闲的线程就复用空闲线程)来服务于这个P;
5、当M系统调用结束时候,这个G会尝试获取一个空闲的P执行,并放入到这个P的本地队列,如果获取不到P,那么这个线程变成休眠状态,加入到空闲线程中,然后这个G就会被放入到全局队列中;
调度器的生命周期
M0&G0
- M0
- 启动程序后的编号为0的主程序
- 在全局变量runtime.m0中,不需要在heap上分配
- 负责执行初始化操作和启动第一个G
- 启动第一个G之后,M0和其他的M就是一样的了
- G0
- 每次启动一个M,第一个创建的goroutine就是GO;
- G0仅用于负责调度的G;
- G0不指向任何可执行的函数;
- 每个M都会有一个自己的G0;
- 在调度或系统调用时会使用M切换到G0来调度;【假设要从G1切换到G2首先要从G1切换到G0,将G1干掉,然后再由G0切换到G2,也就是说G0作为了一个中间桥梁】
- M0的G0会放在全局空间;
可视化的GMP编程序
-
基本的trace编程
- 创建trace文件
- 启动trace
- 停止trace
-
通过go tool trace 工具打开trace文件
-
通过Debug trace查看GMP信息
GODEBUG=schedtrace=1000 ./可执行程序,会有类似如下的输出:
(base) MacBook-Pro go_workspace % GODEBUG=schedtrace=1000 ./test SCHED 0ms: gomaxprocs=8 idleprocs=7 threads=2 spinningthreads=0 idlethreads=0 runqueue=0 [0 0 0 0 0 0 0 0] SCHED 1007ms: gomaxprocs=8 idleprocs=8 threads=5 spinningthreads=0 idlethreads=3 runqueue=0 [0 0 0 0 0 0 0 0] hello GMP SCHED 2015ms: gomaxprocs=8 idleprocs=8 threads=5 spinningthreads=0 idlethreads=3 runqueue=0 [0 0 0 0 0 0 0 0] SCHED 3023ms: gomaxprocs=8 idleprocs=8 threads=5 spinningthreads=0 idlethreads=3 runqueue=0 [0 0 0 0 0 0 0 0] hello GMP SCHED 4024ms: gomaxprocs=8 idleprocs=8 threads=5 spinningthreads=0 idlethreads=3 runqueue=0 [0 0 0 0 0 0 0 0] SCHED 5030ms: gomaxprocs=8 idleprocs=8 threads=5 spinningthreads=0 idlethreads=3 runqueue=0 [0 0 0 0 0 0 0 0] hello GMP SCHED 6041ms: gomaxprocs=8 idleprocs=8 threads=5 spinningthreads=0 idlethreads=3 runqueue=0 [0 0 0 0 0 0 0 0] SCHED 7043ms: gomaxprocs=8 idleprocs=8 threads=5 spinningthreads=0 idlethreads=3 runqueue=0 [0 0 0 0 0 0 0 0] hello GMP SCHED 8051ms: gomaxprocs=8 idleprocs=8 threads=5 spinningthreads=0 idlethreads=3 runqueue=0 [0 0 0 0 0 0 0 0] SCHED 9053ms: gomaxprocs=8 idleprocs=8 threads=5 spinningthreads=0 idlethreads=3 runqueue=0 [0 0 0 0 0 0 0 0] hello GMP
spinningthread 表示处于自旋状态的thread数量
idlethread 处理idle状态的thread
runqueue 表示全局G队列中的G的数量
[0 0 0 0 0 0 0 0] 表示p的本地队列中目前存在G的数量
Go调度器GMP调度场景的全过程分析
场景1:G1创建G3
P拥有G1,M1获取P后开始运行G1,G1创建了G3,为了保证局部性G3优先加入到P1的本地队列。注意老版本中,一个协程创建的程序会被在其他线程上(非自身所关联的P)执行,这不太满足局部性。
场景2:G1执行完毕
G1运行完成后(函数:goexit),M上运行的goroutine切换为G0,GO负责调度协程的切换(函数:schedule)。从P的本地队列取G2,从G0切换到G2,并开始运行G2(函数:execute)。实现了线程M1的复用。
场景3:G2开辟过多的G
当然这种情况也包括也当本地队列满了,我仍然要创建G时的情况
场景4:唤醒正在休眠的M
在创建G时。运行的G会尝试唤醒其他空闲的P和M组合去执行。假定G2唤醒了M2,M2绑定了P2,并运行G0,但P2本地队列没有G,M2此时为自旋线程(没有G但为运行状态,不断寻找G)
场景5:被唤醒的M2从全局队列取批量G
M2尝试从全局队列(简称"GQ")取一批G放到P2的本地队列中,取的个数满足公式:
n = m i n ( l e n ( G Q ) / G O M A X P R O C S + 1 , l e n ( G Q / 2 ) ) n= min(len(GQ)/GOMAXPROCS+1,len(GQ/2)) n=min(len(GQ)/GOMAXPROCS+1,len(GQ/2))
场景6:偷取G的情况
全局队列中没有G了,那么m就要执行work stealing(偷取),从其他的G的P哪里偷取一半的G过来,放到自己的P本地队列,P2从P的本地队列尾部取一半的G,本例中一半的G只有一个G8,放到P2的本地队列中学执行
场景7:自旋线程的最大限制
自旋线程+执行线程<= GOMAXPROCS
场景8:G发生系统调用/阻塞⭐️
自旋线程是抢占G而不是抢占P的,以下面的情况不是直接转移到自旋线程,而是唤醒休眠线程或者创建一个新的线程。
场景9:G发生系统调用/非阻塞
这个时候的M会首先查看原来的P是否有绑定,如果有绑定,M就从空闲的P队列中拿取一个P,如果没有拿到,那么M就回到休眠线程中,刚刚结束阻塞的G就回到全局队列中。