关于Golang的Goroutine

292 阅读9分钟

摘要

goroutine想必Gopher都耳熟能详,但goroutine为什么被称为轻量级的线程、以及goroutine是如何调度的(即GMP调度模型),goroutine的生命周期又是怎样的?本篇文章就从这几个方面聊聊Golang的goroutine

轻量级的线程

goroutine为什么被称为轻量级的线程,将线程和goroutine从以下几个方面对比一下便可看的出来

threadgoroutine
内存占用创建一个线程消耗1~8MB内存,线程运行过程中栈空间不变,为了防止栈溢出需要消耗guard page的区域与其他线程栈空间隔离创建一个goroutine消耗2KB内存,运行过程中动态扩缩容
创建/销毁线程创建/销毁要陷入内核态,开销大goroutine创建/销毁都是在用户态,由runtime管理,开销小
调度切换线程调度切换开销大,切换的过程中需要保存较多上下文和寄存器goroutine调度切换开销小,仅需切换一下pc寄存器
复杂性线程创建销毁开销大,线程与线程之间通信基于share memory需要加锁goroutine开销小,goroutine之间通过channel进行通讯

goroutine调度模型

golang初期的goroutine调度模型是GM,经过几个版本的演进后才到如今的GMP调度模型。下面我就先讲讲GM调度模型,以及它有哪些不足。然后再来讲GMP,这样我们才能掌握透彻GMP调度模型

GM调度模型

G即goroutine,M即物理线程。每个M对应一个G,当goroutine被创建后会存入全局队列中,然后每个M去全局队列中获取goroutine进行执行。当G被阻塞后M也被阻塞,此时需要创建新的M来执行全局队列中的G。活跃的M的数量由GOMAXPROCS变量指定 1656838146(1).png 这个调度模型存在如下几个问题

  1. 单一的全局互斥锁,因为G统一放入global queue中,每次获取G的时候都要争用全局锁
  2. 空间局部性和时间局部性差。由于G都统一挂到全局队列中,那么当前M创建的G就可能会被其他的M获取到然后执行,M与M之间可能来回切换,导致局部性差
  3. 每个M持有mcache内存对象,以及stackalloc。造成内存浪费即内存亲缘性差(注: mcache是golang内存分配模型的重要组成部分,这里可以简单理解为每个G运行时需要用到的堆内存都放在mcache中,mcache存储了各种大小的内存span)

GMP调度模型

鉴于GM的上述种种不足,Go设计团队便引入了一个结构P,设计出来了新的GMP调度模型(如下图)。P可以理解为M执行G所需的上下文环境,M运行所需的mcache和stackalloc内存资源也放到了P中。P的数量等于GOMAXPROCS。可以看出引入了P之后,还引入了本地队列,本地队列的长度为2561656840544(1).png

整体调度逻辑

GMP整体的调度逻辑大概如下:

  • M先与P绑定,M优先从本地队列获取可运行的G,M在执行过程中创建的G也会优先挂载到本地队列,当本地队列满了之后会放一半的G到全局队列中。为防止全局队列饥饿,每1/61个调度轮次M就会去全局队列获取可执行的G。如果本地队列和全局队列都没获取到可执行的G,就会去其他P的local list中抢一些可执行的G到本地队列。为保证公平性每次抢占的P也不是顺序的而是随机一个P,然后选一个小于GOMAXPROCS并和它互为质数的步长进行遍历。如果还没抢占到就继续check全局队列,再没有就去net poll中看看。
  • 可以看的出来,有了本地队列之后就减少了全局锁的争用,而本地队列是lockfree的,但因为workstealing的存在,所以每次去本地队列获取G的时候需要通过CAS原子操作来保证安全性。同时因为M创建的G也被优先挂载到本地队列,G运行时的时间局部性和空间局部性也更好了 1656840101(1).png

syscall

上面描述的都是正常的调度轮转,但是当goroutine由于执行系统调用,又是怎么调度的呢?

  • 首先M和G会阻塞,然后M会与P进行解绑,P会被标记为syscall状态,然后被放入到P的idel list中,此时这个特殊状态的P是不可以被其他M 立刻 绑定的,因为考虑到数据局部性,当之前阻塞的M被立刻唤醒后会优先找上次绑定的P执行,找不到后再从P的idel list中找其他可用的P,再找不到的话就会把G挂载到全局队列中,然后M放入属于M的idel list中。所以如果有大量的syscall阻塞的时候,系统中可能会有大量的M被创建出来导致程序latency或内存泄漏的问题 1656842941(1).png

waiting on channle

Go主张的是 do not communicate by sharing memory,instead share memory by communicating。而goroutine waiting on channel的时候是会阻塞的,面对这种场景Go又是如何进行goroutine调度的呢?

  • 如下图,当G9 waiting on channel的时候G9阻塞,M重新调队头的G2来运行,当channel中有数据之后G9会被重新放入队尾,由于本地队列是FIFO的,当G2运行完后即使channel中已经有数据了,那G9也要等待G5 G4运行完之后才能被调度。如果goroutine之间频繁waiting on channel的话就会导致goroutine频繁的被重新排队,并且因为workstealing算法可能会去P的队尾抢G,这些频繁重新排队的G就有可能被抢占走,导致数据局部性变差。 1656851111(1).png

为了优化上述问题,Go在1.5版本后在P中引入了个 runnext 字段,那些waiting on channel被挂起的G,如果channel中有数据之后runnext字段就会指向等待在此channel的G,让这些G在下次得到优先调度。 1656851747(1).png

network poller

等待网络I/O的goroutine也是会阻塞的,但不会导致M阻塞。等待网络I/O的goroutine处于gopark的状态,然后被推到全局队列中,等待schedual函数或者sysmon触发然后重新调度。可以看的出来等待网络I/O的goroutine调度的优先级其实是没有等待channel等goroutine的优先级高的。所以针对一些网络程序GMP可能会存在一些latency的问题。GMP关于网络调度这一块是通过network poller实现的

sysmon

在golang的runtime中,有一个sysmon监控线程扮演着非常重要的角色,sysmon在goroutine调度过程中也会做一些事情。sysmon在M上运行时无需绑定P,sysmon每20us~10ms循环一次,在变动的循环周期内主要做以下几件事情

  • 释放闲置超过5分之的span物理内存
  • 如果超过两分钟没进行垃圾回收则强制执行
  • 将长时间未处理的netpoll请求放到G全局队列中
  • 向长时间运行的G发出抢占调度(防止G死循环)
  • 将执行超过10ms的M与P强制解绑,将P放入到P的idel list中 下图展示了sysmon在goroutine调度过程中将运行超过10ms的G放入到全局队列中的过程: 1656844922(1).png

spinning

对于GMP模型任何情况下一定存在如下两种自旋

  1. M不带P的找P挂载
  2. M带P的找G运行 自旋的目的是为了不让P和G空闲着,并且自旋的M的数量最多只允许GOMAXPROCS个,因为P最多就GOMAXPROCS个,超过P个数的自旋M是永远找不到P的所以自旋没意义。同时当有类型1的自旋的时候,类型2的自旋就不阻塞,道理也很简单,因为一阻塞P又被另外的M抢走了,系统又回到了同样的自旋状态,所以没必要

all in all

看完上面关于GMP调度模型的分析,想必大家还会有不少疑问,其实golang的GMP调度的模型相当的复杂,我上面只是讲了些大概的原理,更多细节就需要大家自行的阅读设计原文了:Scalable Go Scheduler Design Doc

Goroutine的生命周期

先来看看一个Go程序是如何被启动的(注:我这里也只是讲一个大致流程,更多细节自行请翻阅源码) Go程序启动时先进行一系列的初始化,然后创建一个M0线程和对应的g0,M0线程会创建GOMAXPROCS个P,并且绑定M0和P0,然后新建一个关于runtime.main的G,放到P0的本地队列。runtime.main的G被调度器调度后开始执行,在runtime.main中会启动sysmon;启动GC协程,执行各个init函数,最终执行到用户的main函数。当程序被启动起来后GMP调度模型就正常工作了,每个M在逐步被创建的时候都会创建一个对应的g0,永远运行在当前M上,主要负责调度G到当前M上执行。 对于执行完后的G,也不会被立马销毁,而是会放到P的local freelist队列中,队列长度为64。当local freelist队列满了之后又会推到全局队列中,全局队列又分两个,一个是G中的栈空间被GC过的一个是未被GC的。如下图所示: 1656855963(1).png

总结

限于时间和篇幅优先,关于goroutine的原理性的东西就分享到这了,文中若有任何不正确不严谨的地方,欢迎大家评论指正!