一道问题引发的golang调度
文章目录
说实话,网上对于golang调度的文章很多,但是大部分都是从理论上来描述,描写的精确一点的也依然不够直观。今天跟同学在讨论一道题目的时候又好好的理解了下golang的调度。感觉有所收获,记录一下。
问题
在问题开始之前,我们先出一个题目,交替打印数字和字母。
在网上搜索,以及大部分用户自己想,都会写出来类似于以下的代码
|
|
看着似乎没有什么问题:main被阻塞,直到协程中写入数据。
协程中写入数据的同时会打印结果,然后阻塞,等main中写入数据。
可是,我们运行这段函数10000次,看看结果
中间偶尔出现了这么几个结果
1 2 3 4 5 6 7 8 |
1a2b3c4d5e ... a1b2c3d4e5 1a2b3c4d5e ... 1a2bc34d5e ... 1a2b3cde45 |
为什么会有这个结果?为什么a还能比1先打印出来?
我们稍后解释,先说一遍原理。
golang调度
网上对于golang调度差不多都是这个图,我们也拿过来借鉴一下
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 |
+---------+ +---------+ | | | | | M | | M | | | | | | | | | +----+----+ +----+----+ ^ 本 地 G 队 列 ^ 本 地 G 队 列 | | +--+---+ <---+ +--+---+ <---+ | | +-----+ | | +-----+ | P +<------+ G | | P +<------+ G | | | | | | | | | +--^---+ +--^--+ +--^---+ +--^--+ | | | | +--+--+ +--+--+ +--+--+ +--+--+ | G | | G | | G | | G | | | | | | | | | +-----+ +--^--+ +-----+ +--^--+ | | | | +--+---+ +--+---+ | G | | G | | | | | +------+ +------+ |
G就是我们的go routine, 对应我们代码中的每一个协程;
P就是Processer,是用来调度G,实现M运行G的处理器;
M就是Machine,代表的是物理机的每一个真实的线程。
我们每通过go
关键字创建一个协程,就会被放到一个队列里面。然后每当M运行完一个G的时候,P会从本地队列里面取出来一个新的G,然后让M运行。
似乎很简单,可是G是怎么被放给P的?P和M是怎么交互的呢?
创建
我们都知道,直接
1
|
go do_something() |
就可以直接启动一个协程,这个协程是怎么启动的呢?
我们先看下图
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 |
+---------+ +---------+ | | | | | M | | M | | | | | | | | | +----+----+ +----+----+ ^ 本 地 G 队 列 ^ 本 地 G 队 列 | | +--+---+ <---+ +--+---+ <---+ | | +-----+ | | +-----+ | P +<------+ G | | P +<------+ G | | | | | | | | | +--^---+ +--^--+ +--^---+ +--^--+ | | | | +--+--+ +--+--+ +--+--+ +--+--+ | G | | G | | G | | G | | | | | | | | | +-----+ +--^--+ +-----+ +--^--+ | | | | +--+---+ +--+---+ | G | | G | | | | | +------+ +------+ 全 局 队 列 +-------+ +-------+ +-------+ +-------+ | | | | | | | | | G | | G | | G | | G | | | | | | | | | +-------+ +-------+ +-------+ +-------+ |
我们可以看到,每一个P都会有一个本地队列,保存着一系列的G,把G交给M来运行。
在新启动一个G的时候,golang会将G加入到一个P的队列中,等着被执行。
现在,用户启动了100w个goroutine,怎么办?如果用户只指定了两个P,那确实没办法,只好等着了。但是如果用户指定了较多的P呢?
现在系统检测到存在较多的G之后,会调用一个函数来创建一个新的M
|
|
但是这个M是有go的调度器来操作的,我们是没办法自己创建的。创建成功后,系统会给这个M分配一个P,接下来就是M和P的交互了。
P会从自己的队列里面拿出来一个G给M,让M执行。但是有可能这个P里面也是没有G的。那怎么办呢?
会首先检查全局队列,看看里面有没有G,放到自己的队列里面运行。如果全局队列里面没有,那就去其他P的队列里面抢一半的G过来,让自己运行。反正别的P里面的G也是运行不到,拿过来还能直接运行一会。
可是,也有可能一直获取不到新的G,那就证明确实没有几个G了,M就取消和P的关联,M进入空闲状态。
以上的前提是,系统中存在一个空闲的P,这个P没有和M绑定,这样系统可以创建新的M,使得更多的G可以被并发的执行。
我们提到,会有P从全局队列里面取G来运行,可是全局队列里面的G是怎么来的呢?
从刚刚我们提到的可以得知,新的协程会被存到P里面去,当协程越来越多的时候,本地P的队列就会满了,那么就会截取本地P一半的协程,扔到全局队里里面,留给其他的P使用。
此外,还有一种方式能够进入到全局队列中,我们稍后会提到。
调度
我们看到了一个P是怎么把G运行起来的,可是会存在一个情况,就是一个G在M上面运行,一直运行,不释放了!程序没有被阻塞的地方?这可怎么办?难道其他的协程要等到这个协程运行完才运行?那可不知道等到猴年马月啦!
系统在启动的时候,会启动一个后台监测程序sysmon,来监管着每一个程序的运行状态,如果发现一个协程运行的时间超过了10ms,会执行preemptone
函数将当前协程的preempt
设置为true
,stackguard0
设置为stackPreempt
后续当这个协程发生外部程序调用时(非内联函数的使用),调度器检测到这个标志,就会将这个协程从M上取下来,让P选择其他的M使用。
具体能取下来的情况是什么呢?
- IO 操作
- Channel 阻塞
- system call
- 运行较长时间
当发生以上几种情况的时候,会把G停住,换个新的G来运行。
如果执行了一个内联函数,那么,这个G就不会下来了,因为这个程序根本没有往外面调用,所以,根本没有机会去停止,保存运行时,导致换不了。
现在假设在M0上的G运行了一个不释放CPU的操作
|
|
通过这个操作后,在M0上面的P0会脱离挂载,找到一个新的M2,挂载上去,运行剩下的协程。
当M0上面的协程阻塞结束之后,G该怎么办呢?没有P没法运行呀!
那就会把G放到全局队列里面去,等着别的P来检出来。
相关结论
通过上面的一些介绍,尤其是画的神一般的ascii图,我们可以知道,P的数目是我们是可以手动指定的,默认就是CPU的个数。
当默认是多个P的时候,那么会有G挂靠在多个P上,也就是多个M上(为了方便描述,可以假定这几个协程是并行执行的)。所以,两个协程之间,并不是标准的阻塞,另一个才运行的。而是,只要有可用的M资源,就一定会执行起来。
所以,我们看开头的文章,在协程1往通道写入数据的时候,协程2已经不再阻塞,如果协程1协程2在两个P上面的话,那么实际是并行执行的。只是由于都是是用的stdout写出数据,才导致顺序是我们想要的结果。
我们再看一个考察点挺多的问题
|
|
这个程序,无论是单核还是多核,都永远不会结束。为什么呢?
首先byte
相当uint8
,所以,for循环始终执行,就是相当于
|
|
现在,我们强制执行了runtime.Gosched()
让这个程序停止下,换给其他的协程使用,然后执行runtime.GC()
,垃圾回收,结束程序执行。
我们知道,golang垃圾回收三色标记法,需要stop the world,stop the world的前提是能够把当前的状态全部保存起来。可是这个协程一直在M上运行,停不下来啊!
导致无法GC,卡住了。
如果我们把程序改成非内联的呢?
1 2 3 4 5 6 7 8 9 10 11 |
func main() { go func() { for { fmt.Println("a") } }() runtime.Gosched() runtime.GC() fmt.Println("end") } |
完美退出。