Goroutine是如何调度的

一、什么是Goroutine

Goroutine 可以看作对 thread 加的一层抽象,它是用户态的,更轻量级,可以单独执行。因为有了这层抽象,Gopher 不会直接面对 thread。操作系统却相反,是看不到 goroutine 存在的,安心地执行线程就可以了,线程才是我调度的基本单位。

它和 thread 有什么区别?

1、内存占用
创建一个 goroutine 的栈内存消耗为 2 KB,实际运行过程中,如果栈空间不够用,会自动进行扩容。创建一个 thread 则需要消耗 1 MB 栈内存。

对于一个用 Go 构建的 HTTP Server 而言,对到来的每个请求,创建一个 goroutine 用来处理是非常轻松的一件事。而如果用一个使用线程作为并发原语的语言构建的服务,例如 Java 来说,每个请求对应一个线程则太浪费资源了,很快就会出 OOM 错误(OutOfMermoryError)。

2、创建和销毀
Thread 创建和销毀都会有巨大的消耗,因为要和操作系统打交道,是内核级的,通常解决的办法就是线程池。而 goroutine 因为是由 Go runtime 负责管理的,创建和销毁的消耗非常小,是用户级。

3、切换
当 threads 切换时,需要保存各种寄存器,以便将来恢复:16个通用寄存器、PC(程序计数器)、SP(堆栈指针)、段寄存器、16个XMM寄存器、FP协处理器状态、16个AVX寄存器、所有MSR等。
而 goroutines 切换只需保存三个寄存器:程序计数器、堆栈指针和 BP,也不需要陷入操作系统内核层。

一般而言,线程切换会消耗 1000-1500 纳秒,Goroutine 的切换约为 200 ns,相比之下goroutines 切换成本比 threads 要小得多。

二、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

三、Go调度器模型与演化过程

1、G-M模型

2012年3月28日,Go 1.0正式发布。在这个版本中,每个goroutine对应于runtime中的一个抽象结构:G,而os thread则被抽象为一个结构:M(machine)。

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、全局队列(Global Queue):存放等待运行的G。

2、P的本地队列:同全局队列类似,存放的也是等待运行的G,存的数量有限,不超过256个。
新建G'时,G'优先加入到P的本地队列,如果队列满了,则会把本地队列中一半的G移动到全局队列。

3、P:P的数量决定了系统内最大可并行的G的数量,P的最大作用还是其拥有的各种G对象队列、链表、一些cache和状态。
所有的P都在程序启动时创建,并保存在数组中,默认等于CUP核数,最多有GOMAXPROCS(可配置)个。

4、M:线程想运行任务就得获取P,从P的本地队列获取G。
P队列为空时,M也会尝试从全局队列拿一批G放到P的本地队列,或从其他P的本地队列偷一半放到自己P的本地队列。M运行G,G执行之后,M会从P获取下一个G,不断重复下去。

M的创建时机:按需创建,没有足够的M来关联P并运行其中的可运行的G就会去创建新的M。

这里有一幅图可以形象的说明它们的关系:

地鼠用小车运着一堆待加工的砖。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、go func()的执行流程

1、我们通过 go func()来创建一个goroutine;

2、有两个存储G的队列,一个是局部调度器P的本地队列、一个是全局G队列。新创建的G会先保存在P的本地队列中,如果P的本地队列已经满了就会保存在全局的队列中;

3、G只能运行在M中,一个M必须持有一个P,M与P是1:1的关系。M会从P的本地队列弹出一个可执行状态的G来执行,如果P的本地队列为空,就会去看全局队列,全局也没有就会从其他的MP组合偷取一个可执行的G来执行;

4、一个M调度G执行的过程是一个循环机制;

5、当M执行某一个G时候如果发生了syscall或者其余阻塞操作,M会阻塞,如果当前有一些G在执行,runtime会把这个线程M从P中摘除(detach),然后再创建一个新的操作系统的线程(如果有空闲的线程可用就复用空闲线程)来服务于这个P;

6、当M系统调用结束时候,这个G会尝试获取一个空闲的P执行,并放入到这个P的本地队列。如果获取不到P,那么这个线程M变成休眠状态,加入到空闲线程中,然后这个G会被放入全局队列中。

4、调度器的生命周期


特殊的M0和G0
M0
M0是启动程序后的编号为0的主线程,它在整个运行期专门负责做特定的事情——系统监控(sysmon)。

G0
G0是每次启动一个M都会第一个创建的gourtine,G0仅用于负责调度的G,G0不指向任何可执行的函数, 每个M都会有一个自己的G0。在调度或系统调用时会使用G0的栈空间, 全局变量的G0是M0的G0。

运行流程如图所示:

  1. runtime创建最初的线程m0和goroutine g0,并把2者关联。
  2. 调度器初始化:初始化m0、栈、垃圾回收,以及创建和初始化由GOMAXPROCS个P构成的P列表。
  3. 示例代码中的main函数是main.main,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结束而结束。

参考资料:
也谈goroutine调度器
Golang的协程调度器原理及GMP设计思想
Goroutine调度器
深度解密Go语言之 scheduler

作者:赞 原文地址:https://segmentfault.com/a/1190000042523723

%s 个评论

要回复文章请先登录注册