在学习了解Golang的GPM协程调度模型之前,首先先回顾一下操作系统的进程和线程模型。
进程从字面意思理解就是运行中的程序,是对应用程序运行状态的封装,一个应用程序的启动到关闭过程对应着一个进程的出生到死亡的过程,从进程中可以获取到应用程序运行的相关信息。进程是操作系统调度和执行的基本单位。而线程是存在于进程中一条执行路径,是CPU进行调度和资源分配的最小单位。
线程和进程的区别在于:
在现代操作系统中,线程通常以CPU时间片轮转的方式进行调度,CPU将一个连续的时间划分为多个时间片,指定线程在特定时间片内运行,并且进行轮转,使得多个线程可以在一个CPU核心的调度下,在一个连续的时间并发执行。通常一个操作系统最大的线程并行数为CPU核数总和,也就是一个CPU核心同一时刻只能调度一个线程。
在这种线程调度方式中,需要进行频繁地线程上下文切换,保存线程执行现场以及状态、堆栈信息和计数器,所以使用线程时,如果线程过多调度的性能损耗也会加大,甚至很多时候由于上下文切换开销过大,导致线程并发执行效率不如串行执行效率高,这就是传统的内核态线程调度的缺点。
线程按照其调度器所在空间,可分为内核级线程及用户级线程。
内核级线程的优点是:
通常各大语言的多线程类库都是对操作系统的内核级线程进行封装,以供开发者方便地使用线程,但本质上操作的仍为操作系统内核线程,比如Java、C++等语言,所以能够开启的线程数是有限的,通常不可多过服务器的CPU核心数,如果超过这个数量,那么上下文切换带来的开销就会很大。
用户级线程的优点:
接下来进入正题,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在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在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 就代表一个 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调度器负责协调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 }
需要注意的是runtime.sched.gfreeStack和gfreeNoStack都代表着可运行G列表,但不同的是gfreeNoStack中存储着栈大小不等与默认栈大小的G,在放入该队列前会被释放空间,调度器无论是从gfreeStack还是gfreeNoStack中拿到的G都会进行栈空间检查,如果为0则会进行栈空间初始化。