Golang并发编程-GPM协程调度模型原理及结构分析

1,867 阅读13分钟

一、操作系统的进程和线程模型

1.1、基础知识

在学习了解Golang的GPM协程调度模型之前,首先先回顾一下操作系统的进程和线程模型。

  • 进程从字面意思理解就是运行中的程序,是对应用程序运行状态的封装,一个应用程序的启动到关闭过程对应着一个进程的出生到死亡的过程,从进程中可以获取到应用程序运行的相关信息。进程是操作系统调度和执行的基本单位。而线程是存在于进程中一条执行路径,是CPU进行调度和资源分配的最小单位。

线程和进程的区别在于:

  1. 线程只拥有启动所需的最小资源,一个进程中至少有一个以上的线程,线程又被称为轻量级进程。
  2. 线程的资源和地址空间都取自进程的进程映象。
  3. 线程拥有线程上下文,线程的上下文保存了当前线程所指向代码的PC计数器、一个数据栈、处理器状态和私有的一些数据。
  4. 线程是CPU调度的最小单位,是进程中的一条执行路径,是资源分配的最小单位。

在现代操作系统中,线程通常以CPU时间片轮转的方式进行调度,CPU将一个连续的时间划分为多个时间片,指定线程在特定时间片内运行,并且进行轮转,使得多个线程可以在一个CPU核心的调度下,在一个连续的时间并发执行。通常一个操作系统最大的线程并行数为CPU核数总和,也就是一个CPU核心同一时刻只能调度一个线程。

image.png

在这种线程调度方式中,需要进行频繁地线程上下文切换,保存线程执行现场以及状态、堆栈信息和计数器,所以使用线程时,如果线程过多调度的性能损耗也会加大,甚至很多时候由于上下文切换开销过大,导致线程并发执行效率不如串行执行效率高,这就是传统的内核态线程调度的缺点。

1.2、KST/ULT

线程按照其调度器所在空间,可分为内核级线程及用户级线程。

  • 内核级线程(KST,kernel support thread) 内核级线程依赖于操作系统的线程实现,每个内核级线程都对应着操作系统进程内部的线程实现,线程的调度和控制依赖于操作系统内核的线程,通常操作系统对外提供相应的内核线程操作API供程序使用。操作系统内核可以感知到线程的存在和操作。

image.png 内核级线程的优点是:

  1. 借助操作系统的实现,可利用CPU多核处理器的优势实现并发执行
  2. 一个进程内的线程被阻塞后,其他线程仍然可以继续执行 内核级线程的缺点是线程上下文切换需要借助于操作系统内核,存在两次用户态和内核态的转化,效率较低。

通常各大语言的多线程类库都是对操作系统的内核级线程进行封装,以供开发者方便地使用线程,但本质上操作的仍为操作系统内核线程,比如Java、C++等语言,所以能够开启的线程数是有限的,通常不可多过服务器的CPU核心数,如果超过这个数量,那么上下文切换带来的开销就会很大。

  • 用户级线程(ULT,user level thread) 用户级线程指的是通过线程库来实现线程的调度,线程库运行在用户空间中,不依赖于内核的实现,所以用户级线程(又被称为协程)可以做到对内核无感知,内核不会参与用户级线程的调度和控制,操作系统仍对进程进行直接控制。

image.png 用户级线程的优点:

  1. 用户级线程上下文切换在用户空间完成,无需借助内核,所以不用进行内核态转化,效率高
  2. 用户级线程与具体操作系统无关,只依赖于线程库的实现
  3. 用户级线程可以根据自身需要实现相应的调度算法,而无需受操作系统控制 用户级线程的缺点:
  4. 操作系统侧以进程为调度单位,当线程阻塞时,该进程内所有线程都阻塞
  5. 由于不依赖于操作系统实现,无法直接利用多核CPU的优势

二、Golang的GPM协程调度模型

接下来进入正题,Golang为了减少操作系统内核级线程上下文切换的开销以及提升调度效率,提出了GPM协程调度模型,GPM模型借助了用户级线程的实现思路,通过用户态的协程调度,能够在线程上实现多个协程的并发执行。

GPM三个字母分别表示的是Goroutine、Processor及Machine。

Goroutine代表着Golang中的协程,通过Goroutine封装的代码片段将以协程方式并发执行,是GPM调度器调度的基本单位。

Processor代表执行Goroutine的上下文环境及资源,是GPM调度器中关联内核级线程与协程的中间调度器。

Machine是内核线程的封装,一个M与一个内核级线程一一对应,为Goroutine的执行提供了底层线程能力支持。

GPM三大核心组成结构如下:

image.png GPM中,M与内核线程一一对应,M可以关联多个P,而P也可以调度多个G,P实质上是一个G队列。

image.png

三、M的结构及对应关系

M在Golang的实现中对应着操作系统的一个内核级线程,其包含了需要执行的Goroutine函数以及G的信息,需要注意的,M是无状态的,它的存在是为了执行Goroutine函数。源码位于runtime/runtime2.go中,该结构体核心的字段如下:

type m struct {
   g0      *g    
   mstartfn      func()
   curg          *g      
   p             puintptr 
   nextp         puintptr
   oldp          puintptr 
   lockedg       guintptr
   spinning      bool
   incgo         bool
   ncgo          int32
   // 忽略
 }

各个核心字段的含义如下:

  • g0(m0):g结构体指针,g0是一个G的特殊实例,g0存在于m0这个特殊的M实例之中。m0是在调度程序启动时,由运行时系统创建的第一个M实例,该实例对应该程序拥有的第一个内核线程,而g0则为该内核线程的线程栈,用于执行调度、垃圾回收、栈管理等特殊的任务。除了该g0之外的所有G都为调度系统所创建的用户级G。
  • mstartfn:函数类型,对应着当前内核线程需要执行的Goroutine函数片段。
  • curg:g结构体指针,对应着当前该M相关联的G。
  • p:地址类型,对应着当前该M关联的P。
  • nextp:地址类型,标识有可能与该M存在关联的P。
  • oldp:地址类型,记录上一个与该M关联的P。
  • lockedg:地址类型,标识当前正在锁定该M的G,通过LockOSThread进行G和M的锁定,一旦G和M锁定后,该G只可由该M执行。
  • spinning:布尔类型,表示当前是否正在自旋,自旋则代表当前M正在寻找可执行的G。
  • incgo:布尔类型,表示当前是否正在执行cgo调用。
  • ncgo:int32类型,表示当前正在执行的cgo调用数目。 所以通过curg、mstartfn、p就能够体现GPM调度的核心执行链路了:

image.png

四、P的结构及状态转换

P在Golang的实现中对应着一个调度队列,其中存储着多个G用于调度,需要注意的是P具备状态的,当其达到特定状态时,其含有的G才可被调度,并且P的数量也代表着实际上的最大Goroutine并行执行数(因为一个P需要在运行时取出一个G与M关联,所以当有N个P时最多可同时取出N个G关联M执行)。

P的数量可通过runtime.GOMAXPROCS函数进行设定,默认为当前系统的CPU核数。

image.png

首先看一个P对应的结构体,其源码也位于runtime/runtime2.go中,核心的字段及状态定义如下:

const (
   _Pidle = iota
   _Prunning
   _Psyscall 
   _Pgcstop
   _Pdead
)

type p struct {
   status      uint32 
   schedtick   uint32 
   syscalltick uint32 
   m           muintptr
   runqhead uint32
   runqtail uint32
   runq     [256]guintptr
   runnext guintptr
   gFree struct {
      gList
      n int32
   }
}

p的五个状态如下:

  • Pidle:当前p尚未与任何m关联,处于空闲状态
  • Prunning:当前p已经和m关联,并且正在运行g代码
  • Psyscall:当前p正在执行系统调用
  • Pgcstop:当前p需要停止调度,一般在GC前或者刚被创建时
  • Pdead:当前p已死亡,不会再被调度 P的状态流转图如下:

image.png 在P创建之初,会被置为Pgcstop状态,在完成初始化之后,会马上进入Pidel状态,进入该状态后的P可被调度器调度,当P与某个M相关联时,会进入到Prunning状态,当其执行系统调用时,会进入到Psyscall状态,当P应为全局P列表的缩小而被删除时会进入Pdead状态,不会再进行状态流转和调度。当正在执行的P由于某些原因停止调度时,会统一流转成Pidle空闲状态,等待调度,避免线程饥饿。

P结构体中,重要的字段如下:

  • status:表示当前P的状态,为上述五个状态之一
  • schedtick :调度计数器,每被调度一次则自增1
  • syscalltick:系统调用计数器,每进行一次系统调用则自增1
  • m:即将要关联的m,M的nextp字段对应着该P
  • runq:可运行的G队列,默认容量为256个G
  • runqhead:可运行G队列头,标识目前正在运行的G
  • runnext:下一个将要运行的G
  • gFree:空闲G列表,存储着状态为Gdead的G,当其数目过多时,将会被转移到调度器全局G列表,用于被其他P再次使用(相当于一个G缓存池)

五、G的结构及状态转换

一个 G 就代表一个 goroutine,也与 go 函数对应。我们使用 go 语句时,实际上是向 Go 调度器提交了一个并发任务。Go 的编译器会把 go 语句变成内部函数 newproc 的调用,并把 go 函数以及其参数部分传递给这个函数,G和P一样具有着多个状态进行转换,其状态及结构体源码如下:

const (
   _Gidle = iota
   _Grunnable
   _Grunning 
   _Gsyscall 
   _Gwaiting 
   _Gmoribund_unused
   _Gdead
  _Genqueue_unused
   _Gcopystack
   _Gscan         = 0x1000
   _Gscanrunnable = _Gscan + _Grunnable
   _Gscanrunning  = _Gscan + _Grunning
   _Gscansyscall  = _Gscan + _Gsyscall
   _Gscanwaiting  = _Gscan + _Gwaiting
)

type g struct {
   stack       stack   // offset known to runtime/cgo
   stackguard0 uintptr // offset known to liblink
   stackguard1 uintptr
   m              *m      // current m; offset known to arm liblink
   sched          gobuf 
   atomicstatus   uint32
   waitreason     waitReason // if status==Gwaiting
   preempt        bool       // preemption signal, duplicates stackguard0 = st
   startpc        uintptr         // pc of goroutine function
}

先从G的状态看起,G有如下状态可进行转换:

  • Gidle:当前 G 刚被分配,还未初始化
  • Grunable:正在可运行队列等待运行
  • Gruning:正在运行中,执行G函数
  • Gsyscall:正在执行系统调用
  • Gwaiting:正在被阻塞,一般是该G正在执行网络I/O操作,或正在执行time.Timer、time.Sleep
  • Gdead:已经使用完正在闲置,放入空闲G列表中,可被再次使用(和P不同,P处于Pdead状态则无法被再次调度)
  • Gcopystack:表示当前 G 的栈正在被移动,可能是因为栈的收缩或扩容
  • Gscan:表明当前正在进行GC扫描,由于在GC扫描的过程中肯定会处于某个前置状态,所以又有以下组合
  • Gscanrunable :代表当前 G 正等待运行,同时栈正被 GC 扫描
  • Gscanrunning :表示正处于 Grunning状态,同时栈在被 GC 扫描
  • Gscanwaiting:表示正处于 Gwaiting状态,同时栈在被 GC 扫描
  • Gscansyscall:表示正处于 Gsyscall状态,同时栈在被 GC 扫描

其状态流转图如下:

image.png G结构体中重要字段的含义:

  • stack:当前G所被分配的栈内存空间,由lo及hi两个内存指针组成
  • stackguard0:g0的最大栈内存地址,当超过了这个数值则需要进行栈扩张
  • stackguard1:普通用户G的最大栈内存地址,当超过了这个数值则需要进行栈扩张
  • m:当前关联该G实例的M实例
  • sched:记录G上下文环境,用于上下文切换
  • atomicstatus:G的状态值,表示上述几个状态
  • waitreason:处于Gwaiting的原因
  • preempt:当前G是否可抢占
  • startpc:当前G所绑定的函数内存地址

六、GPM调度器的结构

GPM调度器负责协调G、P、M三者具体的调度工作,每个GO程序中只存在一个GPM调度器,其源码位于runtime/runtime2.go之中,结构体名称为schedt,对应着的全局唯一实例为sched,结构体核心字段如下,直接在代码中注释出来:

type schedt struct {
   // 全局唯一id
   goidgen  uint64
   // 记录的最后一次从i/o中查询G的时间
   lastpoll uint64
   // 互斥锁 
   lock mutex
   // M的空闲链表,通过m.schedlink组成一个M空闲链表
   midle        muintptr
   // 正处于自旋状态的M数量
   nmidle       int32
   // 已经被锁定且正在自旋的M数量
   nmidlelocked int32
   // 下一个M的id,或者是目前已存在的M数量
   mnext        int64
   // M数量的最大值
   maxmcount    int32
   // 已被释放掉的M数量
   nmfreed      int64
   // 系统所开启的协程数量(非用户协程)
   ngsys uint32
   // 空闲P列表
   pidle      puintptr
   // 空闲的P数量
   npidle     uint32
   // 全局的G队列
   // 根据runqhead可以获取队列头的G及g.schedlink形成G链表
   runqhead guintptr
   runqtail guintptr
   // 全局G队列大小
   runqsize int32
   // 等待释放的M列表
   freem *m
   // 是否需要暂停调度(通常因为GC带来的STW)
   gcwaiting  uint32
   // 需要停止但是仍为停止的P数量
   stopwait   int32
   // 实现stopwait事件通知
   stopnote   note
   // 停止调度期间是否进行系统监控任务
   sysmonwait uint32
   // 实现sysmonwait事件通知
   sysmonnote note
}

七、GPM核心容器汇总

image.png

  • 任何 G 都会存在于全局 G 列表中,其余4个容器只存放当前作用域内具有某个状态的 G
  • 从 Gsyscall 状态转出的 G 都会被放到调度器的可运行 G 队列
  • 刚被运行时系统初始化的 G 都会被放入本地 P 的可运行 G 队列
  • 从 Gwaiting 状态转出的 G,有的会被放入本地 P 的可运行 G 队列,有的会被放到调度器的可运行 G 队列,还有的会被直接运行(比如刚完成网络 I/O)
  • 如果本地 P 的可运行队列 G 已满,其中的一半 G 会被转移到调度器的可运行 G 队列
  • 调度器可运行 G 队列遵循 FIFO(先进先出) 需要注意的是runtime.sched.gfreeStack和gfreeNoStack都代表着可运行G列表,但不同的是gfreeNoStack中存储着栈大小不等与默认栈大小的G,在放入该队列前会被释放空间,调度器无论是从gfreeStack还是gfreeNoStack中拿到的G都会进行栈空间检查,如果为0则会进行栈空间初始化。