基础概念
从线程到协程

线程(Thread)与协程(Coroutine)是并发编程中的经典概念:
- 线程是操作系统内核视角下的最小调度单元,其创建、销毁、切换、调度都需要由内核参与
- 协程又称为用户态线程,是用户程序对线程概念的二次封装,和线程为多对一的关系,在逻辑意义上属于更细粒度的调度单元,其调度过程由用户态闭环完成,无需内核的介入
总的来说,线程更加简单直观,天然契合操作系统系统调度模型,而协程是用户态下二次加工的产物,需要引入额外的复杂度,但是相对于线程而言有着更轻粒度和更小的开销
从协程到Goroutine

Golang是一门天然支持协程的语言,Goroutine是其对协程的本土化实现,并且在原生协程的基础上做了很大的优化改进
当我们聊到Goroutine,需要明白这不是一个能被单独拆解的概念,其本身是强依附于GMP体系而生的
通过GMP架构的建设,使得Goroutine相比于原生协程具备着如下核心优势:
- G 与 P、M之间可以动态结合,整个调度过程有着很高的灵活性
- G 栈空间大小可以做动态扩缩,既能做到使用方便,也尽可能的节约资源
此外,Golang中完全屏蔽了线程的概念,围绕着GMP打造的一系列并发工具都以 G 为并发粒度
GMP架构
GMP = Goroutine + Machine + Processor 下面我们对这三个核心组件展开介绍:
Groutine
- G 即Goroutine,是Golang中对协程的抽象
- G 有自己的运行栈、生命周期状态、以及执行的任务函数(用户通过go func指定)
- G 需要绑定在 M 上执行,在 G 视角中,可以将 M 理解为它的CPU
我们可以把GMP理解为一个任务调度系统,那么 G 就是这个系统中所谓的 "任务" , 是一种需要被分配和执行的资源
Machine
- M 即 Machine,是Golang中对线程的抽象
- M 需要和 P 进行结合,从而进入到GMP的调度体系之中
- M 的运行目标始终在 G0 和 G 之间进行切换,当运行 G0 时执行的是 M 的调度流程,负责寻找合适的 "任务",也就是G,当运行G的时候,执行的是 M 获取到的 "任务",也就是用户通过 go func 启动的Goroutine
当我们把GMP理解为一个任务调度系统的时候,那么 M 就是这个系统中的 "引擎",当 M 和 P 结合后,就限定了 "引擎" 的运行是围绕着GMP这条轨道进行的,使得 "引擎" 运行着两个周而复始、不断交替的步骤 --- 寻找任务(执行 G0) 和 执行任务(执行 G)
Processor
- P 即 Processor,是Golang中的调度器
- P 可以理解为 M 的执行代理,M 需要与 P 绑定后,才会进入到 GMP 的调度模式中,因此 P 的数量决定了 G 最大并行数量
- P 是 G 的存储容器,其自带一个本地 G 的队列(local run queue),承载着一系列等待被调度的 G
当我们把 GMP 理解为一个任务调度系统,那么 P 就是这个系统的 "中枢",当其和作为 "引擎"的 M 结合后,才会引导 M 进入GMP的运行模式,同时 P 也是这个系统中存储 "任务" 的 "容器",为 M 提供用于执行的任务资源

结合上图可以看到,承载 G 的容器分为两个部分:
- P 的本地队列 lrq(local run queue) :这是每个 P 私有的 G 队列,通常由 P 自行访问,并发竞争情况较少,因此设计为无锁化结构,通过CAS操作访问
当 M 与 P 结合后,不论是创建 G 还是获取 G,都优先从私有的 lrq 中获取,从而尽可能的减少并发竞争的行为,这里聊到并发情况较少,但并非完全没有,是因为还可能存在来自其他 P 的窃取行为(Steal Work)
- 全局队列 grq(global run queue) :是全局调度模块中的全局共享 G 队列,作为当某个 lrq 不满足条件时的备用容器,因为不同的 M 都有可能访问grq,因此并发竞争比较激烈,访问前需要加全局锁
介绍完 G 的存储容器设计后,接下来聊聊将 G 放入容器和取出容器的流程设计:
-
put G:当某个 G 中通过 go func(...){...}操作创建子Goroutine的时候,会现场将子G添加到当前所在的 P 的 lrq 中(无锁化),如果lrq满了,则会将 G 追加到 grq 中(全局锁),此处采取的思路是 "就近原则"
-
get G:GMP 调度流程中,M 和 P 结合后,运行的G0会不断的寻找合适的G用于执行,此时会采取 "负载均衡" 的思路:
- 优先从当前的 P 的 lrq 中获取 G(无锁化 - CAS)
- 从全局的 grq 中获取 G(全局锁)
- 取 IO 就绪的 G (netpoll机制)
- 从其他 P 的 lrq 中窃取 G (无锁化 - CAS)
在 get 流程中,还有一个细节需要注意,就是在 G0 每经过61次调度循环后,下一次会在处理 lrq 之前优先处理一次 grq,避免因为 lrq 过于忙碌而致使 grq 陷入饥荒的状态
GMP生态
在Golang中已经完全屏蔽了线程的概念,将Goroutine统一为整个语言层面的并发粒度,并遵循着GMP的秩序进行运作
如果把GoLang程序比做一个人的话,那么GMP就是这个人的骨架,支持着他的直立与行走
而在此基础上,紧密围绕着GMP理念打造设计的一系列工具、模块则像是在骨架之上填充的血肉,依附于这套框架存在
我们看一下其中的几个经典案例:
- 内存管理

GoLang的内存管理模块主要继承自 TCMalloc(Thread - Caching - Malloc)的设计思路,其中由契合GMP模型做了因地制宜的适配改造,为每个 P 准备一份私有的高速缓存 -- mcache,能够无锁化的完成一部分 P 本地的内存分配
- 并发工具

在GoLang中的并发工具(例如Mutex、通道Channel等)均契合GMP进行适配改造,保证在执行阻塞操作时,会将阻塞粒度限制在Groutine粒度,而非 M Thread粒度,使得阻塞与唤醒操作都属于用户态行为,无需内核的介入,同时一个 G 的阻塞也完全不会影响 M下的其他 G 的运行
- IO多路复用

在设计IO模型的时候,GoLang采用了Linux系统提供的epoll多路复用技术,然而因为epoll_wait操作引起的 M (Thread)粒度的阻塞
GoLang专门设计了一套netpoll机制,使用用户态的gopark指令实现阻塞操作,使用epoll_wait集合goready指令实现唤醒操作
将IO行为全部控制在了Goroutine粒度,很好的契合了GMP调度体系
GMP详细设计
文字性的理论描述难免过于空洞,G、M、P 并不是抽象的概念,事实上三者在源码中都有着具体的实现,定义代码均位于 runtime/runtime2.go. 下面就从具体的源码中寻求原理内容的支撑和佐证.
G 详设

g (goroutine)
的类型声明如下,其中包含如下核心成员字段:
- stack:g 的栈空间
- stackguard0:栈空间保护区边界. 同时也承担了传递抢占标识的作用(5.3 小节中会进行呼应)
- panic:g 运行函数中发生的 panic
- defer:g 运行函数中创建的 defer 操作(以 LIFO 次序组织)
m:正在执行 g 的 m
(若 g 不为 running 状态,则此字段为空)atomicstatus:g 的生命周期状态
(具体流转规则参见上图)
Go
// 一个 goroutine 的具象类
type g struct{
// g 的执行栈空间
stack stack
/*
栈空间保护区边界,用于探测是否执行栈扩容
在 g 超时抢占过程中,用于传递抢占标识
*/
stackguard0 uintptr
// ...
// 记录 g 执行过程中遇到的异常
_panic *_panic
// g 中挂载的 defer 函数,是一个 LIFO 的链表结构
_defer *_defer
// g 从属的 m
m *m
// ...
/*
g 的状态
// g 实例刚被分配还未完成初始化
_Gidle = iota // 0
// g 处于就绪态. 可以被调度
_Grunnable // 1
// g 正在被调度运行过程中
_Grunning // 2
// g 正在执行系统调用
_Gsyscall // 3
// g 处于阻塞态,需要等待其他外部条件达成后,才能重新恢复成就绪态
_Gwaiting // 4
// 生死本是一个轮回. 当 g 调度结束生命终结,或者刚被初始化准备迎接新生前,都会处于此状态
_Gdead // 6
*/
atomicstatus uint32
// ...
// 进入全局队列 grq 时指向相邻 g 的 next 指针
schedlink guintptr
// ...
}
M 详设

m(machine)
是 go 对 thread 的抽象,其类定义代码中包含如下核心成员:
g0:执行调度流程的特殊 g
(不由用户创建,是与 m 一对一伴生的特殊 g,为 m 寻找合适的普通 g 用于执行)- gsignal:执行信号处理的特殊 g(不由用户创建,是与 m 一对一伴生的特殊 g,处理分配给 m 的 signal)
curg:m 上正在执行的普通 g
(由用户通过 go func(){...} 操作创建)p:当前与 m 结合的 p
go
type m struct{
// 用于调度普通 g 的特殊 g,与每个 m 一一对应
g0 *g
// ...
// m 的唯一 id
procid uint64
// 用于处理信号的特殊 g,与每个 m 一一对应
gsignal *g
// ...
// m 上正在运行的 g
curg *g
// m 关联的 p
p puintptr
// ...
// 进入 schedt midle 链表时指向相邻 m 的 next 指针
schedlink muintptr
// ...
}
此处暂时将 gsignal按下不表,我们可以将 m 的运行目标划分为 g0 和 g ,两者是始终交替进行的:g0 就类似于引擎中的调度逻辑,检索任务列表寻找需要执行的任务;g 就是由 g0 找到并分配给 m 执行的一个具体任务.
P 详设

p (processor)
是 gmp 中的调度器,其类定义代码中包含如下核心成员字段:
- status:p 生命周期状态
- m:当前与 p 结合的 m
runq:p 私有的 g 队列------local run queue,简称 lrq
- runqhead:lrq 中队首节点的索引
- runqtail:lrq 中队尾节点的索引
- runnext:lrq 中的特定席,指向下一个即将执行的 g
go
type p struct{
id int32
/*
p 的状态
// p 因缺少 g 而进入空闲模式,此时会被添加到全局的 idle p 队列中
_Pidle = iota // 0
// p 正在运行中,被 m 所持有,可能在运行普通 g,也可能在运行 g0
_Prunning // 1
// p 所关联的 m 正在执行系统调用. 此时 p 可能被窃取并与其他 m 关联
_Psyscall // 2
// p 已被终止
_Pdead // 4
*/
status uint32// one of pidle/prunning/...
// 进入 schedt pidle 链表时指向相邻 p 的 next 指针
link puintptr
// ...
// p 所关联的 m. 若 p 为 idle 状态,可能为 nil
m muintptr // back-link to associated m (nil if idle)
// lrq 的队首
runqhead uint32
// lrq 的队尾
runqtail uint32
// q 的本地 g 队列------lrq
runq [256]guintptr
// 下一个调度的 g. 可以理解为 lrq 中的特等席
runnext guintptr
// ...
}
schedt详设

schedt 是全局共享的资源模块
,在访问前需要加全局锁:
- lock:全局维度的互斥锁
- midle:空闲 m 队列
- pidle:空闲 p 队列
runq:全局 g 队列------global run queue,简称 grq
- runqsize:grq 中存在的 g 个数
go
// 全局调度模块
type schedt struct{
// ...
// 互斥锁
lock mutex
// 空闲 m 队列
midle muintptr // idle m's waiting for work
// ...
// 空闲 p 队列
pidle puintptr // idle p's
// ...
// 全局 g 队列------grq
runq gQueue
// grq 中存量 g 的个数
runqsize int32
// ...
}
之所以存在 midle 和 pidle 的设计,就是为了避免 p 和 m 因缺少 g 而导致 cpu 空转. 对于空闲的 p 和 m,会被集成到空闲队列中,并且会暂停 m 的运行
调度原理
本章要和大家聊的流程是"调度",所谓调度,指的是一个由用户通过 go func(){...} 操作创建的 g 是如何被 m 上的g0获取并执行的
所以简单来说,调度就是由 g0 -> g 的流转过程,因为流转的过程是由 m 上运行的 g0 主动发起的,无需第三方进行干预
main函数与g
main 函数作为整个 go 程序的入口是比较特殊的存在,它是由 go 程序全局唯一的 m0(main thread)执行的,对应源码位于 runtime.proc.go:
go
//go:linkname main_main main.main
func main_main()
// The main goroutine.
func main(){
// ...
// 获取用户声明的 main 函数
fn := main_main
// 执行用户声明的 main 函数
fn()
// ...
}

除了 main 函数这个特例之外,所有用户通过 go func(){...} 操作启动的 goroutine,都会以 g 的形式进入到 gmp 架构当中.
go
func handle() {
// 异步启动 goroutine
go func(){
// do something ...
}()
}
在上述代码中,我们会创建出一个 g 实例的创建,将其置为就绪状态,并添加到就绪队列中:
- 如果当前 p 对应本地队列 lrq 没有满,则添加到 lrq 中;
- 如果 lrq 满了,则加锁并添加到全局队列 grq 中.
g0 与 g

在每个 m 中会有一个与之伴生的 g0,其任务就是不断寻找可执行的 g. 所以对一个 m 来说,其运行周期就是处在 g0 与 g 之间轮换交替的过程中.
Go
type m struct {
// 用于寻找并调度普通 g 的特殊 g,与每个 m 一一对应
g0 *g
// ...
// m 上正在运行的普通 g
curg *g
// ...
}
在 m 运行中,能够通过几个桩方法实现 g0 与 g 之间执行权的切换:
- g -> g0:mcall、systemstack
- g0 -> g:gogo
对应方法声明于 runtime/stubs.go 文件中:
go
// 从 g 切换至 g0 执行. 只允许在 g 中调用
func mcall(fn func(*g))
// 在普通 g 中调用时,会切换至 g0 压栈执行 fn,执行完成后切回到 g
func systemstack(fn func())
// 从 g0 切换至 g 执行. gobuf 包含 g 运行上下文信息
func gogo(buf *gobuf)
而从 g0 视角出发来看,其在先后经历了两个核心方法后,完成了 g0 -> g 的切换:
- schedule:调用 findRunnable 方法,获取到可执行的 g
- execute:更新 g 的上下文信息,调用 gogo 方法,将 m 的执行权由 g0 切换到 g
上述方法均实现于 runtime/proc.go 文件中:
go
// 执行方为 g0
func schedule(){
// 获取当前 g0
_g_ := getg()
// ...
top:
// 获取当前 p
pp := _g_.m.p.ptr()
// ...
/*
核心方法:获取需要调度的 g
- 按照优先级,依次取本地队列 lrq、取全局队列 grq、执行 netpoll、窃取其他 p lrq
- 若没有合适 g,则将 p 和 m block 住并添加到空闲队列中
*/
gp, inheritTime, tryWakeP := findRunnable()// blocks until work is available
// ...
// 执行 g,该方法中会将执行权由 g0 -> g
execute(gp, inheritTime)
}
// 执行给定的 g. 当前执行方还是 g0,但会通过 gogo 方法切换至 gp
func execute(gp *g, inheritTime bool){
// 获取 g0
_g_ := getg()
// ...
/*
建立 m 和 gp 的关系
1)将 m 中的 curg 字段指向 gp
2)将 gp 的 m 字段指向当前 m
*/
_g_.m.curg = gp
gp.m = _g_.m
// 更新 gp 状态 runnable -> running
casgstatus(gp,_Grunnable,_Grunning)
// ...
// 设置 gp 的栈空间保护区边界
gp.stackguard0 = gp.stack.lo +_StackGuard
// ...
// 执行 gogo 方法,m 执行权会切换至 gp
gogo(&gp.sched)
}
find g
在调度流程中,最核心的步骤就在于,findRunnable 方法中如何按照指定的策略获取到可执行的 g.
主流程:
findRunnable 方法声明于 runtime/proc.go 中,其核心步骤包括::
- 每经历 61 次调度后,需要先处理一次全局队列 grq(globrunqget------加锁),避免产生饥饿;
- 尝试从本地队列 lrq 中获取 g(runqget------CAS 无锁)
- 尝试从全局队列 grq 获取 g(globrunqget------加锁)
- 尝试获取 io 就绪的 g(netpoll------非阻塞模式)
- 尝试从其他 p 的 lrq 窃取 g(stealwork)
- double check 一次 grq(globrunqget------加锁)
- 若没找到 g,将 p 置为 idle 状态,添加到 schedt pidle 队列(动态缩容)
- 确保留守一个 m,监听处理 io 就绪的 g(netpoll------阻塞模式)
- 若 m 仍无事可做,则将其添加到 schedt midle 队列(动态缩容)
- 暂停 m(回收资源)
让渡设计
所谓"让渡",指的是当 g 在 m 上运行时,主动让出执行权,使得 m 的运行对象重新回到 g0,即由 g -> g0 的流转过程.
"让渡"和"调度"一样,也属于第一视角下的转换,该流转过程是由 m 上运行的 g 主动发起的,而无需第三方角色的干预.
结束让渡

当 g 执行结束时,会正常退出,并将执行权切换回到 g0.
首先,g 在运行结束时会调用 goexit1 方法中,并通过 mcall 指令切换至 g0,由 g0 调用 goexit0 方法,并由 g0 执行下述步骤:
- 将 g 状态由 running 更新为 dead
- 清空 g 中的数据
- 解除 g 和 m 的关系
- 将 g 添加到 p 的 gfree 队列以供复用
- 调用 schedule 方法发起新一轮调度
如何理解新一轮的调度?往上翻到 main函数与g 的地方就是新一轮调度
go
// goroutine 运行结束. 此时执行方是普通 g
func goexit1(){
// 通过 mcall,将执行方转为 g0,调用 goexit0 方法
mcall(goexit0)
}
// 此时执行方为 g0,入参 gp 为已经运行结束的 g
func goexit0(gp *g){
// 获取 g0
_g_ := getg()
// 获取对应的 p
_p_ := _g_.m.p.ptr()
// 将 gp 的状态由 running 更新为 dead
casgstatus(gp,_Grunning,_Gdead)
// ...
// 将 gp 中的内容清空
gp.m =nil
// ...
gp._defer =nil// should be true already but just in case.
gp._panic =nil// non-nil for Goexit during panic. points at stack-allocated data.
// ...
// 将 g 和 p 解除关系
dropg()
// ...
// 将 g 添加到 p 的 gfree 队列中
gfput(_p_, gp)
// ...
// 发起新一轮调度流程
schedule()
}
主动让渡
主动让渡指的是由用户手动调用 runtime.Gosched 方法让出 g 所持有的执行权. 在 Gosched 方法中,会通过 mcall 指令切换至 g0,并由 g0 执行 gosched_m 方法,其中包含如下步骤:
- 将 g 由 running 改为 runnable 状态
- 解除 g 和 m 的关系
- 将 g 直接添加到全局队列 grq 中
- 调用 schedule 方法发起新一轮调度
Go
// 主动让渡出执行权,此时执行方还是普通 g
func Gosched() {
// ...
// 通过 mcall,将执行方转为 g0,调用 gosched_m 方法
mcall(gosched_m)
}
// 将 gp 切换回就绪态后添加到全局队列 grq,并发起新一轮调度
// 此时执行方为 g0
func gosched_m(gp *g){
// ...
goschedImpl(gp)
}
func goschedImpl(gp *g){
// ...
// 将 g 状态由 running 改为 runnable 就绪态
casgstatus(gp,_Grunning,_Grunnable)
// 解除 g 和 m 的关系
dropg()
// 将 g 添加到全局队列 grq
lock(&sched.lock)
globrunqput(gp)
unlock(&sched.lock)
// 发起新一轮调度
schedule()
}
阻塞让渡

阻塞让渡指的是 g 在执行过程中所依赖的外部条件没有达成,需要进入阻塞等待的状态(waiting),直到条件达成后才能完成将状态重新更新为就绪态(runnable).
Golang 针对 mutex、channel 等并发工具的设计,在底层都是采用了阻塞让渡的设计模式,具体执行的方法是位于 runtime/proc.go 的 gopark 方法:
- 通过 mcall 从 g 切换至 g0,并由 g0 执行 park_m 方法
- g0 将 g 由 running 更新为 waiting 状态,然后发起新一轮调度
此处需要注意,在阻塞让渡后,g 不会进入到 lrq 或 grq 中,因为 lrq/grq 属于就绪队列. 在执行 gopark 时,使用方有义务自行维护 g 的引用,并在外部条件就绪时,通过 goready 操作将其更新为 runnable 状态并重新添加到就绪队列中.
go
// 此时执行方为普通 g
func gopark(unlockf func(*g, unsafe.Pointer)bool,lockunsafe.Pointer, reason waitReason, traceEv byte, traceskip int){
// 获取 m 正在执行的 g,也就是要阻塞让渡的 g
gp := mp.curg
// ...
// 通过 mcall,将执行方由普通 g -> g0
mcall(park_m)
}
// 此时执行方为 g0. 入参 gp 为需要执行 park 的普通 g
func park_m(gp *g){
// 获取 g0
_g_ := getg()
// 将 gp 状态由 running 变更为 waiting
casgstatus(gp,_Grunning,_Gwaiting)
// 解绑 g 与 m 的关系
dropg()
// g0 发起新一轮调度流程
schedule()
}
与 gopark 相对的,是用于唤醒 g 的 goready 方法,其中会通过 systemstack 压栈切换至 g0 执行 ready 方法------将目标 g 状态由 waiting 改为 runnable,然后添加到就绪队列中.
go
// 此时执行方为普通 g. 入参 gp 为需要唤醒的另一个普通 g
func goready(gp *g, traceskip int) {
// 调用 systemstack 后,会切换至 g0 亚展调用传入的 ready 方法. 调用结束后则会直接切换回到当前普通 g 继续执行.
systemstack(func() {
ready(gp, traceskip, true)
})
// 恢复成普通 g 继续执行 ...
}
// 此时执行方为 g0. 入参 gp 为拟唤醒的普通 g
func ready(gp *g, traceskip int, next bool){
// ...
// 获取当前 g0
_g_ := getg()
// ...
// 将目标 g 状态由 waiting 更新为 runnable
casgstatus(gp,_Gwaiting,_Grunnable)
/*
1) 优先将目标 g 添加到当前 p 的本地队列 lrq
2)若 lrq 满了,则将 g 追加到全局队列 grq
*/
runqput(_g_.m.p.ptr(), gp,next)
// 如果有 m 或 p 处于 idle 状态,将其唤醒
wakep()
// ...
}
抢占设计
最后是关于"抢占"的流程介绍,抢占和让渡有相同之处,都表示由 g->g0 的流转过程,但区别在于,让渡是由 g 主动发起的(第一人称),而抢占则是由外力干预(sysmon thread)发起的(第三人称).

监控线程

在 go 程序运行时,会启动一个全局唯一的监控线程------sysmon thread,其负责定时执行监控工作,主要包括:
- 执行 netpoll 操作,唤醒 io 就绪的 g
- 执行 retake 操作,对运行时间过长的 g 执行抢占操作
- 执行 gcTrigger 操作,探测是否需要发起新的 gc 轮次
执行抢占逻辑的 retake 方法是我们研究的重点,其中根据抢占目标和状态的不同,又可以分为系统调用抢占和运行超时抢占.
-
系统调用抢占
系统调用是 m(thread)粒度的,在执行期间会导致整个 m 暂时不可用,所以此时的抢占处理思路是,将发起 syscall 的 g 和 m 绑定,但是解除 p 与 m 的绑定关系,使得此期间 p 存在和其他 m 结合的机会.
当这个 m 执行完 g 后,检查 syscall 期间,p 是否未和其他 m 结合,如果是的话,直接复用 p,继续执行 g
-
运行超时抢占
除了系统调用抢占之外,当 sysmon thread 发现某个 g 执行时间过长时,也会对其发起抢占操作.
检测到哪些 p 中运行一个 g 的时长超过了 10 ms,然后对其发起抢占操作