18

Golang随谈——浅瞰底层:Go的并发调度模型

 4 years ago
source link: https://studygolang.com/articles/26525
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.

国内喜欢把Go的并发模型称为G-M-P模型,但在网上一查,貌似国外并没有这样的定义,他们喜欢直接称其为Go Scheduler——Go的调度器。不管如何,G-M-P都是Go调度器中的重要概念,它们都定义在sys/runtime/runtime2.go文件中,让我们看看它们都代表什么吧:

  1. G for Goroutine,定义于struct g,其存放着Goroutine的状态信息,如保存着Goroutine的执行堆栈信息、Goroutine的等待信息和变量的GC信息等信息。我们每用关键字go创建一个Goroutine,其在go程序的底层都创建了一个对应的G对象。
  2. M for Machine,定义于struct m,对应着操作系统的工作线程,其和物理处理器线程对应,它负责任务的调度,是实际驱动G运行的实体,但M不负责G的状态的管理,需要切换执行的G时,M会把G的堆栈状态写回到G。实际上,在Go1.1之前,Go只有G-M模型,此前还没有P这个概念,直到Dmitry Vyukov在2012年发表了《Scalable Go Scheduler Design Doc》文章,改文章指出当时G-M模型的问题——用全局唯一的锁和中心化的状态来保护Goroutine相关的操作,Goroutine之间的切换可能会过载,每个M之间的内存缓存问题,以及抢占式线程的阻塞和非阻塞增加了很多开销。因此提出引入Processors的概念到runtime中。
  3. P for Processor,定义于struct p,实现了逻辑上的处理器,它的责任是负责提供相关的上下文环境,负责内存缓存的管理,负责Goroutine任务队列等。P是G和M的中间层,M会和P先绑定,然后M会不断地从P的任务队列中取出G并恢复执行(取出操作无锁,因为没有资源竞争的问题),当P的任务队列都处理完,P再从全局队列中返回一个G来执行(取出操作有锁,因为这可能会和其它的P竞争),当全局队列也没有G时,则从其它的P窃取G来执行。当再也没有G可以被执行时,M和P会被解绑,进入休眠状态。P的个数默认为物理线程数。

Go的调度器才是概念的重点,而G-M-P则是Go调度器组成的重要部分。P和M通常是对应的,简单来说,P管理着一组G,并负责把G挂载到M上运行。当一个M长时间在运行同一个M时,runtime会创建一个新的M,阻塞的G所在的P会把其余的G挂载到新的M上,当这个阻塞的G阻塞完成或者结束时,该旧M会被回收。

关于G的运行,因为M在运行G的过程中,会遇到需要上下文切换的情况——当一个被运行的G要被切换时,需要对G的执行现场进行保护,以便下次被调度执行时进行现场恢复,Go调度器的做法是,把M的堆栈和M所需的寄存器(SP、PC等)保存到G中,就可以实现现场保护了。当调度器再次运行该G的时候,M通过访问G中保存的寄存器进行现场恢复,即可从上次中断的位置继续执行。

Go这种调度器使用了m:n调度的技术,即复用或调度m个goroutine到n个OS线程。其中m的调度由Go程序的运行时负责,n的调度由OS负责。这让m的调度可以在用户态下完成,不会造成内核态和用户态见的频繁切换。同时,内存的分配和释放,文件的IO等,Go也通过内存池和netpoll等技术,尽量减少内核态的调用。

G的状态

Goroutine在生命周期的不断的阶段,会有不同的G状态。而通过分析G的状态,有助于我们了解Goroutine的调度。在runtime2.go文件中定义了,G有以下几种状态——idle, runnable, running, syscall, waiting, dead, copystack六种非GC状态,以及scan, scanrunnable, scan running, scansyscall, scanwaiting六种对应的GC状态,而moribund_unused和enqueue_unused两种状态已经被废弃了:

  1. _Gidle for idle,意思是这个goroutine刚被创建出来,还未被进行初始化。因为它的值为0,所以刚被创建出来的g对象都是_Gidle。但在runtime库仅有的两处调用中,创建出来的g都马上被赋值为_Gdead,这是为了g在添加到被GC观察之前,用于躲避trackbacks和stack scan,因为这个g对象在必要的处理前,还不是一个真正的goroutine。
  2. _Grunnable for runnable,意思是这个goroutine已经在运行队列,在这种情况下,goroutine还未执行用户代码,M的执行栈还不是goroutine自己的。
  3. _Grunning for running,意思是goroutine可能正在执行用户代码,M的执行栈已经由该goroutine所拥有,此时对象g不在运行队列中。这个状态值要待分配给M和P之后,交由M和P来设定。
  4. _Gsyscall for system scall,意思是这个goroutine正在执行系统调用,而不是正在执行用户代码。和_Grunning一样,goroutine拥有了执行栈,也不在运行队列中。这个状态值只能由分配给的M来设定。
  5. _Gwaiting for waiting,意思是goroutine在运行时被阻塞,它既不执行用户代码,也不在运行队列。它被记录在其它的地方,例如管道等待队列——channel wait queue,因此当需要该goroutine的时候,该goroutine可以马上就绪,这也是goroutine和channel的底层实现方式。这个时候,执行栈不被该g对象所拥有,除非一个管道正在做读或者写执行栈里面数据的操作。除了以上这类型的情况,在一个goroutine进入_Gwaiting之后尝试获取其执行栈,都是不安全的。
  6. _Gdead for dead,意思是这个goroutine在当前不被使用,这种情况可能是goroutine刚被创建出来,或者已经执行完毕退出并被放到释放列表中。当一个G执行完毕并正在退出时,和G被添加到释放列表时,G和G的执行栈都是M所拥有的。
  7. _Gcopystack for copy stack,意思是这个goroutine的执行栈已经被移动,这个goroutine即不执行用户代码,也不在运行队列。这种状态是_Grunning的时候,出现了执行栈空间不足或者过大,需要扩容或者GC的情况下发生,是进行执行栈扩容或者收缩时的中间状态。
  8. _Gscan系列,用于标记正在被GC扫描的状态,这些状态是由_Gscan=0x1000再加上_GRunnable, _Grunning, _Gsyscall和_Gwaiting的枚举值所产生的,这么做的好处是直接通过简单的运算即可知道被Scan之前的状态。当被标记为这系列的状态时,这些goroutine都不会执行用户代码,并且它们的执行栈都是被做该GC的goroutine所拥有。不过_Gscanrunning状态有点特别,这个标记是为了阻止正在运行的goroutine切换成其它状态,并告诉这个G自己扫描自己的堆栈。正是这种巧妙的方式,使得Go语言的GC十分高效。

从以上列举的状态可以分析出,无论是处理waiting的业务,还是处理GC,goroutine是高效的。但当要调用system call的时候则不然,低效的系统调用业务代码,会影响Go应用的运行性能,幸好Go语言中已经封装了很多能代替用户低效的系统调用的工具,例如网络调用看似是系统调用,但Go实际上已经在底层封装了netpoll,我们应该尽量使用这些库来避免系统调用。不合理的设计导致频繁copy stack和会导致频繁GC的设计等设计,这些都是我们需要注意的。

2020.02

tou.hwang

延伸阅读:

  1. The Go scheduler
  2. Scheduling In Go : Part I - OS Scheduler
  3. Scheduling In Go : Part II - Go Scheduler
  4. Scheduling In Go : Part III - Concurrency

参考资料:

  1. Scalable Go Scheduler Design Doc
  2. Go 1.13.1 源代码

虽然有参考以下文章,但我觉得里面的概念和说法未必都是对的,所以我对其内容我作重新思考和甄别,再作参考。


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK