调度器的出现
单进程操作系统,一次只能运行一个任务,不存在调度器。
多进程操作系统,允许并发执行多个任务,有调度器,多个任务根据时间片来分别执行任务。(时间片执行完会强制执行下一个时间片中的任务,进程主动让出或挂起也会让出时间片)
缺点:时间片执行带来的是进程间的切换,涉及到系统调用和中断(上下文环境复制,要和寄存器打交道)。
即从用户态陷入系统态,再切入到用户态。即调度很消耗资源,进程一旦多起来,可能切换时间要大于执行时间。
因为进程和线程的创建成本高(线程
4M
),切换成本高,体现在CPU
占用高,内存占用高,所以出现了协程。线程又分用户线程,内核线程,分指不同环境下的线程。
CPU
只能看到内核线程。内核线程是真线程,能分配到CPU
资源。起初为一个用户线程绑定一个内核线程。用户线程又叫协程。即协程绑定在内核线程之上。
N:1问题:会引起阻塞。1:1无效果。M:N实现复杂,能用多核。
协程跟线程是有区别的,线程由CPU
调度是抢占式的,协程由用户态调度是协作式的,一个协程让出CPU
后,才执行下一个协程。
Golang 协程调度器历史版本
2012 之前被废弃版本
初版
有一个全局协程队列,G队列
。多个线程M
,访问或者放回G
,都需要通过G队列
,并且加互斥锁。这个版本没有调度器,创建销毁调度全由内核线程完成。**M**
需要去取回**G**
执行**G**
中的任务。
缺点:
1.创建销毁调度都需要_M_
对其整个队列上锁,容易形成锁竞争。
2._M_
转移会造成延迟和额外的系统负载。(_M1_
拿回_G1_
执行任务的过程中,这个_G1_
又创建了一个_G2_
,按道理来说新创建的_G2_
,应当在_M1_
中执行,但是这时新创建的_G2_
是不一定会在M1
中执行的,即造成了_M_
转移)
第二版
- 全局队列(Global Queue):存放等待运行的
G
。 - P的本地队列:同全局队列类似,存放的也是等待运行的
G
,存的数量有限,不超过256
个。新建G'
时,G'
优先加入到P
的本地队列,如果队列满了,则会把本地队列中一半的G
移动到全局队列
。 - P列表:所有的
P
都在程序启动时创建,并保存在数组中,最多有GOMAXPROCS
(可配置)个。 - M:线程想运行任务就得获取
P
,从P的本地队列
获取G
,P的本地队列
为空时,M
也会尝试从全局队列
拿一批G
放到P的本地队列
,或从其他P的本地队列
偷一半放到自己P的本地队列
。M
运行G
,G
执行之后,M
会从P
获取下一个G
,不断重复下去。
其中M
是动态的,空闲可能会被回收,阻塞P
会再创建一个M
。也可以手动设置。
协程的设计策略:
- 避免频繁创建和销毁线程,可以对线程
M
进行复用。当M
从P
中取不到G
了也不会闲着,从而避免频繁创建和销毁。有两种机制不闲着:
work stealing机制
。空闲时(即当前绑定的P
中没有G
了),就会去尝试从其他P
中偷取回G
放入自己的P
中。偷不到就尝试从全局队列中偷取,再偷不到就空闲了。hand off机制
。当M
因为G
进行系统调用阻塞时,M
释放绑定的P
,阻塞G
和M
直接绑定。然后把P
转移给其他空闲的M
执行。阻塞G
绑定的M
执行完毕后,M
进入睡眠或者销毁(避免频繁创建)。
以上机制应该是由协程调度器来执行。M
只负责执行。其M
的创建销毁等也是由协程调度器来执行。可以将M
理解为是死的,无情的执行机器,是由调度器喂G
给M
吃。因为M
在内核态,内核不会做这些事情。
- 实现并行。看上图,多个
M
在P队列
中拿G
执行。 - 抢占。
coroutine
是需要等待让出。goroutine
是抢占式,最多占用CPU 10ms
。这是和coroutine不同的地方之一。问题。既然一个goroutine最多只占用10ms,那要是没执行完怎么办?放在后面 链接。
4.全局队列。当M执行work stealing从其他G中偷取G又没偷到时,则去全局队列中偷取回P中再执行。
go func()调度流程
超过10ms怎么办?
https://www.lmlphp.com/user/150984/article/item/2704287/
那就会有个问题,如果一个系统调用或者G任务执行太长,他就会一直占用这个线程,由于本地队列的G任务是顺序执行的,其它G任务就会阻塞了,怎样中止长任务的呢?(这个地方我找了好久~o(╯□╰)o)
这样滴,启动的时候,会专门创建一个线程sysmon,用来监控和管理,在内部是一个循环:
1. 记录所有P的G任务计数schedtick,(schedtick会在每执行一个G任务后递增)
2. 如果检查到 schedtick一直没有递增,说明这个P一直在执行同一个G任务,如果超过一定的时间(10ms),就在这个G任务的栈信息里面加一个标记
3. 然后这个G任务在执行的时候,如果遇到非内联函数调用,就会检查一次这个标记,然后中断自己,把自己加到队列末尾,执行下一个G
4. O(∩_∩)O哈哈~,如果没有遇到非内联函数(有时候正常的小函数会被优化成内联函数)调用的话,那就惨了,会一直执行这个G任务,直到它自己结束;如果是个死循环,并且GOMAXPROCS=1的话,恭喜你,夯住了!亲测,的确如此
对于一个G任务,中断后的恢复过程:
1. 中断的时候将寄存器里的栈信息,保存到自己的G对象里面
2. 当再次轮到自己执行时,将自己保存的栈信息复制到寄存器里面,这样就接着上次之后运行了。 ~(≧▽≦)/~
GO调度器的启动周期
M0:第一个线程,负责执行初始化操作和启动第一个G。启动完成后该M0就和其他M一样了。
G0:每个M都有一个G0,仅用于调度M要使用的G。每次调度先会切换到G0,执行G0的调度程序,G0就会拉取下一个要执行的G到M中执行。