调度器的出现

  1. 单进程操作系统,一次只能运行一个任务,不存在调度器。

  2. 多进程操作系统,允许并发执行多个任务,有调度器,多个任务根据时间片来分别执行任务。(时间片执行完会强制执行下一个时间片中的任务,进程主动让出或挂起也会让出时间片)

缺点:时间片执行带来的是进程间的切换,涉及到系统调用和中断(上下文环境复制,要和寄存器打交道)。
即从用户态陷入系统态,再切入到用户态。即调度很消耗资源,进程一旦多起来,可能切换时间要大于执行时间。

  1. 因为进程和线程的创建成本高(线程4M),切换成本高,体现在CPU占用高,内存占用高,所以出现了协程。

  2. 线程又分用户线程,内核线程,分指不同环境下的线程。CPU只能看到内核线程。内核线程是真线程,能分配到CPU资源。起初为一个用户线程绑定一个内核线程。用户线程又叫协程。即协程绑定在内核线程之上。

N:1问题:会引起阻塞。1:1无效果。M:N实现复杂,能用多核。

协程调度器和 GMP - 图1

协程跟线程是有区别的,线程由CPU调度是抢占式的,协程由用户态调度是协作式的,一个协程让出CPU后,才执行下一个协程。

Golang 协程调度器历史版本

G=协程。M=线程。P=processor处理器。

2012 之前被废弃版本

初版

有一个全局协程队列,G队列。多个线程M,访问或者放回G,都需要通过G队列,并且加互斥锁。这个版本没有调度器,创建销毁调度全由内核线程完成。**M**需要去取回**G**执行**G**中的任务。

缺点:
1.创建销毁调度都需要_M_对其整个队列上锁,容易形成锁竞争。
2._M_转移会造成延迟和额外的系统负载。(_M1_拿回_G1_执行任务的过程中,这个_G1_又创建了一个_G2_,按道理来说新创建的_G2_,应当在_M1_中执行,但是这时新创建的_G2_是不一定会在M1中执行的,即造成了_M_转移)

第二版

协程调度器和 GMP - 图2

  1. 全局队列(Global Queue):存放等待运行的G
  2. P的本地队列:同全局队列类似,存放的也是等待运行的G,存的数量有限,不超过256个。新建G'时,G'优先加入到P的本地队列,如果队列满了,则会把本地队列中一半的G移动到全局队列
  3. P列表:所有的P都在程序启动时创建,并保存在数组中,最多有GOMAXPROCS(可配置)个。
  4. M:线程想运行任务就得获取P,从P的本地队列 获取GP的本地队列为空时,M也会尝试从全局队列一批G放到P的本地队列,或从其他P的本地队列一半放到自己P的本地队列M运行GG执行之后,M会从P获取下一个G,不断重复下去。

其中M是动态的,空闲可能会被回收,阻塞P会再创建一个M。也可以手动设置。

协程的设计策略:

  1. 避免频繁创建和销毁线程,可以对线程M进行复用。当MP中取不到G了也不会闲着,从而避免频繁创建和销毁。有两种机制不闲着:
  • work stealing机制 。空闲时(即当前绑定的P中没有G了),就会去尝试从其他P中偷取回G放入自己的P中。偷不到就尝试从全局队列中偷取,再偷不到就空闲了。
  • hand off机制。当M因为G进行系统调用阻塞时,M释放绑定的P阻塞GM直接绑定。然后把P转移给其他空闲的M执行。阻塞G绑定的M执行完毕后,M进入睡眠或者销毁(避免频繁创建)。

以上机制应该是由协程调度器来执行。M只负责执行。其M的创建销毁等也是由协程调度器来执行。可以将M理解为是死的,无情的执行机器,是由调度器喂GM吃。因为M在内核态,内核不会做这些事情。

  1. 实现并行。看上图,多个MP队列中拿G执行。
  2. 抢占。coroutine是需要等待让出。goroutine是抢占式,最多占用CPU 10ms。这是和coroutine不同的地方之一。问题。既然一个goroutine最多只占用10ms,那要是没执行完怎么办?放在后面 链接

4.全局队列。当M执行work stealing从其他G中偷取G又没偷到时,则去全局队列中偷取回P中再执行。

go func()调度流程

协程调度器和 GMP - 图3
1.G执行完后,会放回到本地队列当中,这是个队列。

超过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中执行。

协程调度器和 GMP - 图4