Go调度程序(Go调度器)
Go调度器本质上也是一个结构体。Go调度器并不是运行在某个专用内核线程中的程序,调度程序会运行在若干已存在的M(或者说内核线程)之中。换句话说,运行时系统中几乎所有的M都会参与调度任务的执行。
下面是这个结构体的几个比较重要的字段
字段名称 |
数据类型 |
用途简述 |
gcwaiting |
uint32 |
表示是否需要因一些任务而停止调度 |
stopwait |
int32 |
表示需要停止但仍未停止的P的数量 |
stopnote |
note |
用于实现与stopwait 相关的事件通知机制 |
sysmonwait |
uint32 |
表示在停止调度期间系统监控任务是否在等待 |
sysmonnote |
note |
用于实现与sysmonwait 相关的事件通知机制 |
在这张表中的字段都与需要停止调度的任务有关。在Go运行时系统中,一些任务在执行前是需要暂停调度的,例如垃圾回收和发生panic。为了描述方便,我暂且把这类任务称为串行运行时任务。
字段gcwaiting 、stopwait 和stopnote 都是串行运行时任务执行前后的辅助协调手段。
Go的调度流程(一轮调度)
上图的过程描述了go程序从开启进程到G开始运行的过程,也就是调度器如何寻找可运行的G并开始运行G的过程。
在一轮调度的开始处,调度器会先判断当前M是否已被锁定。M和G是可以成对地锁定在一起。我们已经知道,Go的调度器会按照一定策略动态地关联M、P和G(也就是说M可以运行不同P下的不同G),其优势就在于无需用户程序的任何干预。然而,在极少数的情况下,用户会需要把某个M和某个G锁定在一起,让这些G只能在这个M运行。
锁定M和G的操作可以说是为CGO准备的。CGO代表了Go中的一种机制,是Go程序和C程序之间的一座桥梁。有些C语言的函数库(比如OpenGL)会用到线程本地存储技术。这些函数库会把一些数据存储在当前内核线程M的私有缓存中。因此,包含了调用此类C函数库的代码的G会变得特殊。它们在特定时期内只能与同一个M产生关联,否则就有可能丢失其存储在某个内核线程的私有缓存中的数据。我们可以通过调用runtime.LockOSThread 函数,把当前的G与当时运行它的那个M锁定在一起,也可以通过调用runtime.UnlockOSThread 函数解除当前G与某个M的锁定。一个M只能与一个G锁定。
当然M与G的锁定问题不是重点,因为我们很少这样做。我们继续说一轮调度的流程:如果调度器判断当前M未与任何G锁定,那么一轮调度的主流程就会继续进行。这时,调度器会检查是否有运行时串行任务(即会导致暂停所有M和G运行的任务)正在等待执行。这类任务在执行时需要停止Go调度器。官方称此种停止操作为“Stop the world”,简称STW。一旦串行任务执行完成,该M就会被唤醒,一轮调度也会继续。
如果调度器在此关于锁定和运行时串行任务的判断都为假,就会开始真正的可运行G寻找之旅。一旦找到一个可运行的G,调度器就会在判定该G未与任何M锁定之后,立即让当前M运行它。
总的来说,一轮调度就是调度器让M找到G,并且运行这个G的过程。
一轮调度是Go调度器最核心的流程,在很多情况下都会被触发。例如,在用户进程刚启动时一轮调度流程会首次启动并使封装main函数的那个G被调度运行。又例如,某个G的运行的阻塞、结束、退出系统调用,以及其栈的增长,都会使调度器进行一轮调度。除此之外,用户程序对某些标准库函数的调用也会触发一轮调度流程。比如,对runtime.Gosched 函数的调用会让当前的G暂停运行,并让出CPU给其他的G。这其中就有一轮调度流程的功劳。又比如,调用runtime.Goexit 函数会结束当前G的运行,也会进行一轮调度。
全力查找可运行的G
这个是指调度器会为空闲的M去找可运行的G,让M把这些G运行起来,尽量不让M闲着。调度器会从各种G队列中和网络I/O轮询器中(存放着正在进行网络IO的G容器)获取可运行的G给M去运行,甚至还会进行工作窃取(本地M从其他P的G队列偷取可运行的G来运行)。
go调度器需要查找可运行的G时可以调用runtime.findrunnable 函数,该函数会返回一个处于Grunnable 状态的G进行查找可运行G
调度器全力查找可运行的G的顺序是:先从本地P的可运行G队列和调度器的可运行G队列找,没有的话再从网络IO轮询器中找,还没有的话则从其他P的可运行G队列找(工作窃取),如果还找不到调度器就会停止当前的M。在之后的某个时刻,该M被唤醒之后,它会重新进入“全力查找可运行的G”的子流程。
关于网络IO轮询器,我们需要知道当某一个G进行网络读写时,会进入一个等待状态(Gwaiting状态)并被放入到网络IO轮询器中,并且多路复用器(如epoll或者kqueue)会监听这个G的读事件或写事件并在这个事件上绑定一个回调函数,而当这个G完成了读写操作时,就会触发事件上的回调函数从而将这个G从Gwaiting状态转为Grunnable的可运行状态。而调度器从网络IO轮询器获取可运行的G只需直接拿Grunnable状态的G即可。
如果某个M正在查找可运行的G(或者说是调度器通过某个M查找可运行的G),那么这个M所处的状态就是自旋状态,我们称之为自旋M,因为此时M既没有睡眠也没有在工作,而是在不停寻找G,这个过程是消耗CPU的,是cpu密集型操作。
如果一个M(我们称之为M1)始终没有找到可运行的G而休眠,其他M(称之为M2)在工作过程会顺便找到可运行的G,并在找到时将之前睡眠的M唤醒。实际上M2是将自己预连的P转交给M1进行关联,让M1获取这个P中的G进行工作。
这里需要说明的是,我们已知Go的网络请求接口全部使用的异步非阻塞的系统调用,因此G进行网络IO操作的时候是不会阻塞住内核线程M的,这意味着可以减少内核对内核线程M的上下文切换,和减少M的空闲时间。当然了,Go的网络读写其底层虽然是用的异步非阻塞的系统调用,但是Go把他们封装成类看似阻塞的方法提供给我们 使用。
更多细节
g0和m0
每个M都会拥有一个特殊的G,一般称为M的g0 。g0 管辖的内存称为M的调度栈。可以说,M的g0 对应于操作系统为相应线程创建的栈。
g0 不是由“go 函数()”的方式生成的,而是由Go运行时系统在初始化M时创建的。g0 一般用于执行调度、垃圾回收、栈管理等方面的任务(因此其实调度器本质上就是g0)。顺便提一下,M还会拥有一个专用于处理信号的G,称为gsignal。
除了g0 之外,其他由M运行的G都可以视作用户级别的G,简称用户G,而它们的g0 和gsignal 都可以称为系统G。Go运行时系统会进行切换,以使M都可以交替运行用户G和它的g0 。g0 不会被阻塞,也不会包含在任何G队列或列表中。此外,它的栈也不会在垃圾回收期间被扫描。
除了每个M都有属于它自己的g0 之外,还存在一个runtime.g0。runtime.g0用于执行引导程序(go程序初始化时运行的程序),它运行在Go程序拥有的第一个内核线程中,这个内核线程也称为runtime.m0。runtime.m0的g0 即runtime.g0。
其实main函数或者说main goroutine就是在runtime.m0上运行的。而执行到 go func1() 的时候,会创建或者复用一个G来封装这个func1且并发的运行func1
Go的垃圾回收
Go的GC是基于CMS(Concurrent Mark-Sweep,并发的标记-清扫)算法。调度器会自动在特定条件下执行垃圾回收(GC会在为Go程序分配的内存翻倍增长时被触发),系统监测任务在必要时也会进行强制GC。不
GC有3种执行模式
gcBackgroundMode 。并发地执行垃圾收集(也可称标记)和清扫。
gcForceMode 。串行地执行垃圾收集(即执行时停止调度),但并发地执行清扫。
gcForceBlockMode 。串行地执行垃圾收集和清扫。
调度器驱使的自动GC和系统监测任务中的强制GC,都会以gcBackgroundMode 模式执行。但是,前者会检查Go程序当前的内存使用量,仅当使用增量过大时才真正执行GC。然而,后者会无视这个前提条件。
我们可以通过环境变量GODEBUG 控制自动GC的并发性。只要使其值包含gcstoptheworld=1 或gcstoptheworld=2 ,就可以让GC的执行模式由gcBackgroundMode 变为gcForceMode 或gcForceBlockMode 。这相当于让并发的GC进入(易于)调试模式。
简单地讲,GC会在为Go程序分配的内存翻倍增长时被触发。
关闭自动GC就意味着我们要在程序中手动GC了,否则程序占用的内存即使不再使用也不会被回收。调用runtime.GC 函数可以手动触发一次GC,不过这个GC函数会阻塞调用方直到GC完成。这种情况下的GC会以gcForceBlockMode 模式执行。这里不再展开它的GC算法详情,感兴趣的读者可以自行查资料。
runtime包的一些控制goroutine的方法
runtime.GOMAXPROCS
设置运行时系统(runtime)的P的最大数量。这样做会引起“Stop the world”,所以我强烈建议应用程序尽量早地,并且更好的方式是设置环境变量GOMAXPROCS 。
runtime.Goexit 函数
调用runtime.Goexit 函数之后,会立即使当前goroutine的运行终止,而其他goroutine并不会受此影响。runtime.Goexit 函数在终止当前goroutine之前,会先执行该goroutine中所有还未执行的defer 语句。
该函数会把被终止的goroutine置于Gdead 状态,并将其放入本地P 的自由G列表,然后触发调度器的一轮调度流程。
请注意,千万不要在主goroutine中调用runtime.Goexit 函数,否则会引发panic。
runtime.Gosched 函数
runtime.Gosched 函数的作用是暂停当前goroutine的运行(其实就是手动切换,从运行当前goroutine变为运行其他goroutine,也可以说是人为干预调度)。当前goroutine会被置为Grunnable 状态,并放入调度器的可运行G队列;这也是使用“暂停”这个词的原因。经过调度器的调度,该goroutine马上就会再次运行。
runtime.NumGoroutine 函数
runtime.NumGoroutine 函数在被调用后,会返回当前Go运行时系统中处于非Gdead 状态的用户G的数量。这些goroutine被视为“活跃的”或者“可被调度运行的”。该函数的返回值总会大于等于1 。
runtime.LockOSThread 函数和runtime.UnlockOSThread 函数
对前者的调用会使当前goroutine与当前M锁定在一起,而对后者的调用则会解除这样的锁定。多次调用前者不会造成任何问题,但是只有最后一次调用会生效,可以想象成对同一个变量的多次赋值。另一方面,即使在之前没有调用过前者,对后者的调用也不会产生任何副作用。
runtime/debug.SetMaxStack 函数
runtime/debug.SetMaxStack 函数的功能是约束单个goroutine所能申请栈空间的最大size。已知,在main 函数及init 函数真正执行之前,主goroutine会对此数值进行默认设置。250 MB和1 GB分别是在32位和64位的计算机系统下的默认值。
runtime/debug.SetMaxThreads 函数
runtime/debug.SetMaxThreads 函数的作用是对Go运行时系统所使用的内核线程的数量(也可以认为是M的数量)进行设置。在引导程序中,该数量被设置成了10 000。这对于操作系统和Go程序来说,都已经是一个足够大的值了。
如果调用此函数时给定的新值比运行时系统当前正在使用的M的数量还要小的话,就会引发一个panic。如果程序运行中M增长到大于M最大数量的设定,运行时系统就会发起一个同样的panic。
在实际开发中,这些函数几乎不会被用到,而且也要少用。
最后需要提醒一点的是:
虽然G的创建和切换成本远小于M,但是也不意味着我们可以无限制的创建大量的G,因为大量的G创建会增加栈内存的占用和调度器负担加重,对内存和cpu的消耗都是我们需要考虑的。因此,即使是使用goroutine也要有意识的去限制其并发量不能过大。
Goroutine阻塞(重点)
G阻塞分为以下4种情况:
1. 由于原子、互斥量或通道操作调用导致 Goroutine 阻塞
调度器将把当前阻塞的 Goroutine 切换出去(相当于调用了runtime.Goexit,把当前的goroutine从运行中的状态换成Grunnable状态并放入可运行G队列中),当前M会换其他的goroutine运行。
该情况的G阻塞不会阻塞内核线程M,因此不会导致M的上下文切换而只涉及到G的切换。
如下图:
如果G1是被chan阻塞住的话,情况会和上图中的有所不同,G1不会回到当前P的可运行G队列,而是被挂在了chan的recvq和sendq这两个队列中,我们将在下一节介绍chan通道的时候对此说明。
2. 由于网络请求和文件IO 操作导致 Goroutine 阻塞
之前说过go的网络IO其实用的是异步非阻塞的系统调用结合多路复用器的事件监听机制,所以当前G会由于IO事件未就绪而被切换,但是不会阻塞M。另外,Go的文件IO其实也是使用的异步的系统调用和多路复用的事件监听优化。
该情况的G阻塞不会阻塞内核线程M,因此不会导致M的上下文切换而只涉及到G的切换。
如下图:
3. 由于调用阻塞的系统调用时导致的goroutine阻塞
这种情况会导致当前M阻塞,内核会进行M的切换。而与当前M关联的当前P不会等待M的阻塞,因为这意味当前P下的所有G都无法执行,所以此时P会与当前M解除关联,转而关联到另一个内核线程M2,M2可能时新创建的内核线程,也可能时之前空闲的内核线程被唤醒来执行P的G
如下图:
4. 由于调用time.Sleep或者ticker计时器会导致Goroutine阻塞
这种情况和情况2相似,不会阻塞当前M。