编程语言
首页 > 编程语言> > Golang并发编程-GPM协程调度模型原理及结构梳理

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

作者:互联网

文章目录

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

1.1、基础知识

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

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

线程和进程的区别在于:

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

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

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

1.2、KST/ULT

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

内核级线程的优点是:

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

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

在这里插入图片描述

用户级线程的优点:

  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三大核心组成结构如下:
在这里插入图片描述

GPM中,M与内核线程一一对应,M可以关联多个P,而P也可以调度多个G。
在这里插入图片描述

三、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
   // 忽略
 }

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

四、P的结构及状态转换

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

P的数量可通过runtime.GOMAXPROCS函数进行设定,默认为当前系统的CPU核数。
在这里插入图片描述

首先看一个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的五个状态如下:

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

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

五、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有如下状态可进行转换:

其状态流转图如下:
在这里插入图片描述

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核心容器汇总

在这里插入图片描述

需要注意的是runtime.sched.gfreeStack和gfreeNoStack都代表着可运行G列表,但不同的是gfreeNoStack中存储着栈大小不等与默认栈大小的G,在放入该队列前会被释放空间,调度器无论是从gfreeStack还是gfreeNoStack中拿到的G都会进行栈空间检查,如果为0则会进行栈空间初始化。

标签:GPM,协程,操作系统,状态,调度,Golang,线程,内核
来源: https://blog.csdn.net/pbrlovejava/article/details/117604004