一、什么是Goroutine
在go语言中,每一个并发的执行单元叫作一个goroutine。
当一个程序启动时,其主函数在一个单独的goroutine中运行,我们叫它main goroutine,新的goroutine会用go语句来创建。在语法上,go语句是一个普通的函数或方法调用前加上关键字go,go语句会使其语句中的函数在一个新创建的goroutine中运行。
f() // call f(); wait for it to return
go f() // create a new goroutine that calls f(); don't wait
Goroutine 可以看作对 thread 加的一层抽象,它是用户态的,更轻量。因为有了这层抽象,Gopher 不会直接面对 thread。操作系统却相反,是看不到 goroutine 存在的,安心地执行线程就可以了,线程才是它调度的基本单位。
Goroutine 和 thread 有什么区别?
1、内存占用
创建一个 goroutine 的栈内存消耗为 2 KB,实际运行过程中,如果栈空间不够用,会自动进行扩容。创建一个 thread 则需要消耗 1 MB 栈内存。
对于一个用 Go 构建的 HTTP Server 而言,对到来的每个请求,创建一个 goroutine 用来处理是非常轻松的一件事。而如果用一个使用线程作为并发原语的语言构建的服务,例如 Java 来说,每个请求对应一个线程则太浪费资源了,很快就会出 OOM 错误(Out Of Mermory Error)。
2、创建和销毀
Thread 创建和销毀都会有巨大的消耗,因为要和操作系统打交道,是内核级的,通常解决的办法就是线程池。而 goroutine 因为是由 Go runtime 负责管理的,创建和销毁的消耗非常小,是用户级。
3、切换
当 threads 切换时,需要保存各种寄存器,以便将来恢复:包括所有的寄存器,16个通用寄存器、程序计数器、栈指针、段寄存器、16个XMM寄存器、FP协处理器、16个 AVX寄存器、所有的MSR等等。
goroutine的保存和恢复只需要三个寄存器:程序计数器、栈指针和DX寄存器,也不需要陷入操作系统内核层。
一般而言,线程切换会消耗 1000-1500 纳秒,Goroutine 的切换约为 200 ns,相比之下goroutines 切换成本比 threads 要小得多。
二、线程的并发模型
线程是操作系统内核的最小调度单元,我们上面所说的goroutine 也是基于线程,根据用户线程与内核线程的对应关系,分为以下几种并发模型:
1、用户级线程模型
用户线程与内核线程是多对一(N : 1)的映射模型,即一个进程中所有创建的线程都只和同一个内核线程在运行时动态绑定,python实现的协程库gevent就都属于这种方式。
这些线程的创建、销毁以及多线程之间的协调等操作都是由用户自己的线程库来负责而无须借助系统调用来实现,这种实现方式相比内核级线程可以做的很轻量级,对系统资源的消耗会小很多,因此可以创建的线程数量与上下文切换所花费的代价也会小得多。但该模型有个原罪:并不能做到真正意义上的并发,假设在某个用户进程上的某个用户线程因为一个阻塞调用(比如 I/O 阻塞)而被 CPU 给中断(抢占式调度)了,那么该进程内的所有线程都被阻塞,整个进程被挂起。
2、内核级线程模型
用户线程与内核线程 KSE 是一对一(1 : 1)的映射模型,也就是每一个用户线程绑定一个实际的内核线程,而线程的调度则完全交付给操作系统内核去做,Java 的线程便是基于此实现的。
这种模型的优势是实现简单,直接借助操作系统内核的线程以及调度器,所以 CPU 可以快速切换调度线程,于是多个线程可以同时运行,因此相较于用户级线程模型它真正做到了并行处理;但它的劣势是,由于直接借助了操作系统内核来创建、销毁和以及多个线程之间的上下文切换和调度,因此资源成本大幅上涨,且对性能影响很大。
3、两级线程模型
用户线程与内核是多对多(N : M)的映射模型,该模型对于前面两种模型扬长避短:首先,区别于用户级线程模型,两级线程模型中的一个进程可以与多个内核线程关联,也就是说一个进程内的多个线程可以分别绑定一个自己的 KSE,这点和内核级线程模型相似;其次,又区别于内核级线程模型,它的进程里的线程并不与 KSE 唯一绑定,而是可以多个用户线程映射到同一个 KSE,当某个 KSE 因为其绑定的线程的阻塞操作被内核调度出 CPU 时,其关联的进程中其余用户线程可以重新与其他 KSE 绑定运行。
这种模型实现比较复杂,Go 语言中的 runtime 调度器就是采用的这种实现方案,实现了 Goroutine 与 KSE 之间的动态关联,不过 Go 语言的实现更加高级和优雅;该模型为何被称为两级?即用户调度器实现用户线程到 KSE 的『调度』,内核调度器实现 KSE 到 CPU 上的『调度』。
三、Go调度器的作用
提到“调度”,我们首先想到的就是操作系统对进程、线程的调度。操作系统调度器会将系统中的多个线程按照一定算法调度到物理CPU上去运行。
这种传统支持并发的方式有诸多不足:
一个thread的代价已经比进程小了很多了,但我们依然不能大量创建thread,因为除了每个thread占用的资源不小之外,操作系统调度切换thread的代价也不小;
一个Go程序对于操作系统来说只是一个用户层程序,它的眼中只有thread,它甚至不知道Goroutine的存在。将这些goroutines按照一定算法放到“CPU”上执行的程序就称为goroutine调度器或goroutine scheduler。在操作系统层面,Thread竞争的“CPU”资源是真实的物理CPU,但在Go程序层面,各个Goroutine要竞争的”CPU”资源是操作系统线程。
说到这里goroutine scheduler的任务就明确了:
goroutine调度器通过使用与CPU数量相等的线程减少线程频繁切换的内存开销,同时在每一个线程上执行额外开销更低的 Goroutine 来降低操作系统和硬件的负载。
Workflow
+-------------------- sysmon ---------------//------+
| |
| |
+---+ +---+-------+ +--------+ +---+---+
go func() ---> | G | ---> | P | local | <=== balance ===> | global | <--//--- | P | M |
+---+ +---+-------+ +--------+ +---+---+
| | |
| +---+ | |
+----> | M | <--- findrunnable ---+--- steal <--//--+
+---+
|
mstart
|
+--- execute <----- schedule
| |
| |
+--> G.fn --> goexit --+
1\. go func() 语句创建G。
2\. 将G放入P的本地队列(或者平衡到全局队列)。
3\. 唤醒或新建M来执行任务。
4\. 进入调度循环
5\. 尽力获取可执行的G,并执行
6\. 清理现场并且重新进入调度循环
四、Go调度器模型与演化过程
1、G-M模型
2012年3月28日,Go 1.0正式发布。在这个版本中,每个goroutine对应于runtime中的一个抽象结构:G,而os thread则被抽象为一个结构:M。
M想要执行、放回G都必须访问全局G队列,并且M有多个,即多线程访问同一资源需要加锁进行保证互斥/同步,所以全局G队列是有互斥锁进行保护的。
这个结构虽然简单,但是却存在着许多问题:
1、所有goroutine相关操作,比如:创建、重新调度等都要上锁;
2、M转移G会造成延迟和额外的系统负载。比如当G中包含创建新协程的时候,M创建了G1,为了继续执行G,需要把G1交给M1执行,也造成了很差的局部性,因为G1和G是相关的,最好放在M上执行,而不是其他M1。
3、系统调用(CPU在M之间的切换)导致频繁的线程阻塞和取消阻塞操作增加了系统开销。
2、G-P-M模型
在Go 1.1中实现了G-P-M调度模型和work stealing算法,这个模型一直沿用至今:
在新调度器中,除了M(thread)和G(goroutine),又引进了P(Processor)。P是一个“逻辑Proccessor”,每个G要想真正运行起来,首先需要被分配一个P(进入到P的本地队列中),对于G来说,P就是运行它的“CPU”,可以说:G的眼里只有P。但从Go scheduler视角来看,真正的“CPU”是M,只有将P和M绑定才能让P的runq中G得以真实运行起来。
3、抢占式调度
G-P-M模型的实现算是Go scheduler的一大进步,但Scheduler仍然有一个头疼的问题,那就是不支持抢占式调度,导致一旦某个G中出现死循环或永久循环的代码逻辑,那么G将永久占用分配给它的P和M,位于同一个P中的其他G将得不到调度,出现“饿死”的情况。更为严重的是,当只有一个P时(GOMAXPROCS=1)时,整个Go程序中的其他G都将“饿死”。
这个抢占式调度的原理则是在每个函数或方法的入口,加上一段额外的代码,让runtime有机会检查是否需要执行抢占调度。
五、G-P-M模型的深入理解
1、基本概念
1、全局队列:存放等待运行的G;
2、P的本地队列:同全局队列类似,存放的也是等待运行的G,存储数量不超过256个;
3、G:Groutine协程,调度系统的最基本单位,拥有运行函数的指针、栈、上下文;
4、P:Processor,调度逻辑处理器,拥有各种G对象队列、链表、一些cache和状态,P的数量决定了系统内最大可并行的G的数量;
所有的P都在程序启动时创建,并保存在数组中,默认等于CUP核数,最多有GOMAXPROCS(可配置)个;
5、M:代表实际工作的执行者,对应到操作系统级别的线程,每个M就像一个勤劳的工作者,从各种队列中找到可运行的G;
这里有一幅图可以形象的说明它们的关系:
地鼠用小车运着一堆待加工的砖,M就可以看作图中的地鼠,P就是小车,G就是小车里装的砖。
goroutine 的创建与调度循环是一个生产-消费流程,整个 go 程序的运行就是在不断地执行 goroutine 的生产与消费流程。
2、调度器的设计策略
2.1、复用线程
避免频繁的创建、销毁线程,而是对线程的复用。
1)work stealing机制
当本线程无可运行的G时,尝试从其他线程绑定的P偷取G,而不是销毁线程。
2)hand off机制
当本线程因为G进行系统调用阻塞时,线程释放绑定的P,把P转移给其他空闲的线程执行。
2.2、利用并行
GOMAXPROCS设置P的数量,最多有GOMAXPROCS个线程分布在多个CPU上同时运行。
2.3、抢占
Go 程序启动时,运行时会去启动一个名为 sysmon 的 M,会定时向长时间(>=10MS)运行的 G 任务发出抢占调度,防止其他goroutine被饿死。
3、goroutine的调度流程
我们通过 go func()来创建一个goroutine;
有两个存储G的队列,一个是局部调度器P的本地队列、一个是全局G队列。新创建的G会先保存在P的本地队列中,如果P的本地队列已经满了就会保存在全局的队列中;
G只能运行在M中,一个M必须持有一个P,M与P是1:1的关系;
一个M调度G执行的过程是一个循环机制,M会从P的本地队列弹出一个可执行状态的G来执行,如果P的本地队列为空,就会去看全局队列,全局也没有就会从其他的MP组合偷取一个可执行的G来执行,如果其他队列也获取不到M就会自旋;
如果 G 被阻塞在某个 channel 操作或网络 I/O 操作上时,G 会被放置到某个等待(wait)队列中,而 M 会尝试运行 P 的下一个可运行的 G。如果这个时候 P 没有可运行的 G 供 M运行,那么 M 将解绑 P,并进入挂起状态。
如果 G 被阻塞在某个系统调用(system call)上,那么不光 G 会阻塞,执行这个 G 的 M也会解绑 P,与 G 一起进入挂起状态。如果此时有空闲的 M,那么 P 就会和它绑定,并继续执行其他 G;如果没有空闲的 M,但仍然有其他 G 要去执行,那么 Go 运行时就会创建一个新 M(线程)。
当一个新的goroutine创建或者有多个goroutine进入等待运行状态时,会先判断是否有自旋的M,有则使用自旋的M,当没有自旋的M,但m空闲队列中有空闲的M则会唤醒M,否则会创建一个新的M。
4、调度器的生命周期
特殊的M0和G0
M0
M0是启动程序后的编号为0的主线程,M0负责执行初始化操作和启动第一个G,还在整个运行期专门负责做特定的事情——系统监控(sysmon)。
G0
G0是每次启动一个M都会第一个创建的gourtine,G0仅用于负责调度的G,G0不指向任何可执行的函数, 每个M都会有一个自己的G0。在调度或系统调用时会使用G0的栈空间, 全局变量的G0是M0的G0。
调度器的运行流程如图所示:
1. runtime创建最初的线程m0和goroutine g0,并把二者关联。
2. 调度器初始化:初始化m0、栈、垃圾回收,以及创建和初始化由GOMAXPROCS个P构成的P列表。
3. runtime中也有1个main函数——runtime.main,代码经过编译后,runtime.main会调用main.main,程序启动时会为runtime.main创建goroutine,称它为main goroutine吧,然后把main goroutine加入到P的本地队列。
4. 启动m0,m0已经绑定了P,会从P的本地队列获取G,获取到main goroutine。
5. G拥有栈,M根据G中的栈信息和调度信息设置运行环境
6. M运行G
7. G退出,再次回到M获取可运行的G,这样重复下去,直到main.main退出,runtime.main执行Defer和Panic处理,或调用runtime.exit退出程序。
调度器的生命周期几乎占满了一个Go程序的一生,runtime.main的goroutine执行之前都是为调度器做准备工作,runtime.main的goroutine运行,才是调度器的真正开始,直到runtime.main结束而结束。
六、小结
Go调度器的本质是把大量的goroutine任务分配到少量线程上运行,利用多核并行,实现更强大的并发。
参考资料: