golang goroutine的调度
1、什么是协程?
协程是一种用户态的轻量级线程。
2、进程、线程、协程的关系和区别:
* 进程拥有自己独立的堆和栈,既不共享堆,亦不共享栈,进程由操作系统调度。
* 线程拥有自己独立的栈和共享的堆,共享堆,不共享栈,线程亦由操作系统调度(标准线程是的)。
* 协程和线程一样共享堆,不共享栈,协程由程序员在协程的代码里显示调度。
* 协程和线程的区别是:协程避免了无意义的调度,由此可以提高性能。
* 执行协程只需要极少的栈内存(大概是4~5KB),默认情况下,线程栈的大小为1MB。
goroutine就是一段代码,一个函数入口,以及在堆上为其分配的一个堆栈。所以它非常廉价,我们可以很轻松的创建上万个goroutine,但它们并不是被操作系统所调度执行。
runtime。GOMAXPROCS(runtime。NumCPU()) // go version>=1.5的时候,GOMAXPROCS的默认值就是go程序启动时可见的操作系统认为的CPU个数。
注意:在go程序中使用的操作系统线程数量包括:正服务于cgo calls的线程,阻塞于操作系统calls的线程,所以go程序中使用的操作系统线程数量可能大于GOMAXPROCS的值。
3、调度
要理解协程的实现,首先需要了解go中的三个非常重要的概念,它们分别是G(goroutine)、M(machine)和P(process):
G (goroutine)
G是goroutine的头文字,goroutine可以解释为受管理的轻量线程,goroutine使用go关键词创建。
举例来说,func main() { go other() },这段代码创建了两个goroutine,一个是main,另一个是other,注意main本身也是一个goroutine。
goroutine的新建,休眠,恢复,停止都受到go运行时的管理。
goroutine执行异步操作时会进入休眠状态,待操作完成后再恢复,无需占用系统线程。
goroutine新建或恢复时会添加到运行队列,等待M取出并运行。
M (machine)
M是machine的头文字,在当前版本的golang中等同于系统线程。
M可以运行两种代码:
* go代码,即goroutine,M运行go代码需要一个P。
* 原生代码,例如阻塞的syscall,M运行原生代码不需要P。
M会从运行队列中取出G,然后运行G,如果G运行完毕或者进入休眠状态,则从运行队列中取出下一个G运行,周而复始。
有时候G需要调用一些无法避免阻塞的原生代码,这时M会释放持有的P并进入阻塞状态,其他M会取得这个P并继续运行队列中的G。
go需要保证有足够的M可以运行G,不让CPU闲着,也需要保证M的数量不能过多。
P (process)
P是process的头文字,代表M运行G所需要的资源。
虽然P的数量默认等于cpu核心数,但可以通过环境变量GOMAXPROC修改,在实际运行时P跟cpu核心并无任何关联。
P也可以理解为控制go代码的并行度的机制,
* 如果P的数量等于1,代表当前最多只能有一个线程(M)执行go代码;
* 如果P的数量等于2,代表当前最多只能有两个线程(M)执行go代码。
执行原生代码的线程数量不受P控制。
因为同一时间只有一个线程(M)可以拥有P,P中的数据都是锁自由(lock free)的,读写这些数据的效率会非常的高。
备注:每个P会维护一个本地的运行队列,除了每个P拥有一个本地的运行队列外,还存在一个全局的运行队列。
为什么需要创造一个用户空间调度器?
POSIX线程API是对现有Unix进程模型的一个非常大的逻辑扩展,而且线程获得了非常多的跟进程相同的控制。
比如,线程有它自己的信号掩码,线程能够被赋予CPU affinity功能(就是指定线程只能在某个CPU上运行),
线程能被添加到[cgroups]中,线程所用到的资源也可以被查询到。
所有的这些控制增大了Go程序使用goroutines时根本不需要的特性的开销,当你的程序有100,000个线程的时候,这些开销会急剧增长。
另外一个问题是,基于Go模型,操作系统不能给出特别好的决策。
比如,当运行一次垃圾收集的时候,Go的垃圾收集器要求所有线程都被停止而且要求内存要处于一致状态。
这个涉及到要等待全部运行时线程到达一个点,系统事先知道在这个点内存是一致的。
当很多被调度的线程分散在随机的点上的时候,结果就是你不得不等待他们中的大多数到达一致状态。
Go调度器能够作出这样的决策,就是只在内存保持一致的点上进行调度。
这就意味着,当程序为垃圾收集而停止的时候,程序只须等待在一个CPU核上处于活跃运行状态的线程即可。
目前有三个常见的线程模型:
一个是N:1的,即多个用户空间线程运行在一个OS线程上。这个模型可以很快的进行上下文切换,但是不能利用多核系统(multi-core systems)的优势。
另一个模型是1:1的,即可执行程序的一个线程匹配一个OS线程。这个模型能够利用机器上的所有核心的优势,但是上下文切换非常慢,因为它不得不陷入OS(trap through the OS)。
Go试图通过M:N的调度器去获取这两个世界的全部优势。它在任意数目的OS线程上调用任意数目的goroutines。你可以快速进行上下文切换,并且还能利用你系统上所有的核心的优势。这个模型主要的缺点是它增加了调度器的复杂性。
原理:
P的数量在初始化由GOMAXPROCS决定;
程序要做的就是添加G;
G的数量超出了M的处理能力,且还有空余P的话,runtime就会自动创建新的M;
M拿到P后才能干活,取G的顺序:本地运行队列 > 全局运行队列 > 其他P的运行队列,如果所有运行队列都没有可用的G,M会归还P并进入休眠。
一个G如果发生阻塞等事件会进行阻塞,G发生上下文切换条件:
* 系统调用;
* 读写channel;
* gosched主动放弃,会将G扔进全局队列;
一个G发生阻塞时,M0让出P,由M1接管其任务队列;当M0执行的阻塞调用返回后,再将G0扔到全局队列,自己则进入睡眠(因为没有P,无法干活)。
例子:
当G0调用一个系统调用的时候。因为一个线程不能既执行代码同时又阻塞到一个系统调用上,则需要移交对应于这个线程的P以让这个P可以被调度。
M0放弃了它的P以保证其它的M1可以运行它。调度器确保有足够的线程来运行所有的P。
M1可能仅仅是系统为了处理G0的系统调用而被创建出来,或者它可能来自一个线程池。
这个处于系统调用中的线程M0将会保持在这个导致系统调用的G0上,因为从技术上来说,它仍然在执行,虽然阻塞在OS里了。
当这个系统调用返回的时候,这个线程必须尝试获取一个P1来运行这个返回的G0,操作的正常模式是从其它所有线程M中的其中一个线程Mn中“盗取”一个Pn。
如果“盗取”不成功,它就会把它的G0放到一个全局运行队列中,然后把自己放到线程池中或者转入睡眠状态。
这个全局运行队列是各个P在运行完自己的本地运行队列后用来获取新G的地方。
各个P也会周期性的检查这个全局运行队列上的G,否则,全局运行队列上的G可能因得不到执行而被饿死。
备注:Go程序要在多线程上运行的原因就是因为要处理系统调用,哪怕GOMAXPROCS等于1。运行时(runtime)使用调用系统调用的goroutines,而不是线程。
“盗取”:
当一个P运行完要被调度的所有G的时候。如果各个P的本地运行队列里的G的数目不均衡,改变就会发生了,
否则会导致一个P1在执行完它的本地运行队列里的G后就会结束,尽管系统中仍然有许多G要执行。
所以为了保持运行Go代码,一个P能够从全局运行队列中获取G,但是如果全局运行队列中也没有G了,那么P就不得不从其它Pn的运行队列获取G了。
当一个P完成自己的任务后,它就会尝试“盗取”另一个P运行队列中G的一半。这将确保每个P总是有活干,然后反过来确保所有线程M尽可能处于最大负荷。
备注:goroutine是按照抢占式调度的,一个goroutine最多执行10ms就会换作下一个。
这个和目前主流系统的的cpu调度类似(按照时间分片)
windows:20ms
linux:5ms-800ms
有疑问加站长微信联系(非本文作者)