【Go笔记】深入协程设计与调度原理
协程的声明状态
- 如图:
_Gidle
- 刚开始创建的状态,初始化之后会变成_Gdead
_Gdead
- 被初始化和销毁时候的状态_Grunnable
- 在运行队列中,等待运行_Grunning
- 正在被运行,已经分配了M和P_Gwaiting
- 运行时被锁定,不能执行用户代码,在gc和channel通信的时候常常遇到_Gsyscall
- 正在执行系统调用_Gpreempted
- 被强制抢占_Gcopystack
- 发现需要扩容或缩小协程栈空间,正在从旧栈转移到新栈_Gscan
,_Gscanrunnable
,_Gscanrunning
是垃圾回收阶段的状态
特殊协程g0与一般协程的切换
- 每个线程中都有一个特殊的协程g0,运行在操作系统线程栈上,执行协程调度方面的代码
- 当用户协程退出或被抢占的时候,需要重新执行协程调度,需要从用户协程g切换到协程g0
- 调度完成后,协程g0又会切换回用户协程g。协程经历过
g -> g0 -> g
的过程,完成了一次调度循环 - 协程切换的过程也叫上下文切换,需要保存当前协程的执行现场,保存在
g.gobuf
结构体中 g.gobuf
主要保存三个寄存器值,分别为rsp(函数调用栈栈顶), rip(程序要执行的下一条指令的地址), rbp(函数栈帧的起始位置)- g0作为特殊的调度写成,执行的函数和流程相对固定,且为了避免栈溢出,g0的栈会重复使用
线程本地存储与线程绑定
- 是一种计算机编程方法,使用线程本地的静态或全局内存
- 线程本地存储中的变量只对当前线程可见,是“私有”的
- 操作系统一般使用FS/GS段寄存器存储
- Go中的线程本地存储的是结构体m中的
m.tls
的地址 - 在任意线程内部,通过线程本地存储,可以在任意时刻获取绑定到当前线程上的协程g,结构体m,逻辑处理器P,特殊协程g0等信息
调度循环
- 指从g0开始,找到接下来要运行的g,再从g切换回g0开始新一轮调度的过程
- 流程图:
调度策略
- 调度的核心策略位于
runtime/proc.go
中 - 首先检测是否为gc阶段,如果是则检测是否需要后台标记协程
- 等待被调度执行的协程存储在运行队列中,分为局部运行队列和全局运行队列
- 局部运行队列为每个P特有的长度为256的数组,模拟循环队列,有
runqhead
,runqtail
,runnext
,末尾插入,头部取出。如果有就执行runnext
,没有就去runq
里面取 - 一般是先查找每个P的局部运行队列,获取不到时就从全局队列中拿。为了避免只循环往复执行局部队列中的G,P中每执行61次调度,就优先从全局队列中获取一个G到当前P中
- 执行局部队列的G时,按照如是优先级与顺序
¶获取本地运行队列
- 如果
runnext
没有G的时候,就要从局部运行队列中拿G - 当
runqhead
不等于runqtail
的时候,说明队列里面有东西,直接从头部获取一个 - 因为可能存在其他P抢任务造成同时访问的情况,所以访问时需要加锁
¶获取全局运行队列
- 全局运行队列是一根链表,先根据P的数量平分G,同时转移的数量不能超过局部队列容量的一半
- 如果局部运行队列已经满了,那就会将局部运行队列的一半放入全局运行队列
¶都找不到?
- 虽然很少见,但是还是有可能发生
- 会寻找是否有准备好运行的网络协程
¶协程窃取
- 当局部队列,全局队列,网络协程都没有可用协程的时候,需要从其他P的局部队列中窃取可用的协程来执行
- 为了保证选择P的公平和随机,Go采用了一种数学方法来计算出需要遍历局部变量的P,具体可Google
- 找到可用的P之后,将P局部运行队列G个数的一半放到自己的运行队列中
调度时机
- 分为主动调度、被动调度和抢占调度
¶主动调度
- 用户可以手动在代码中执行
runtime.Gosched
函数,不过大多数情况下,用户并不需要执行这个函数,在编译阶段就会检查插入代码 - 无限for循环的场景由于没有抢占的时机,1.14之前的版本无法被强占,1.14之后使用操作系统的信号机制来强制抢占,但是速度依然比不上用户直接调用
runtime.Gosched
函数 - 主动调度就是现将当前协程切换为g0,取消G与M之间的绑定关系,将G放入全局运行队列,执行
schdule
函数开始新一轮循环
¶被动调动
- 指协程在休眠、channel堵塞、I/O堵塞、执行gc等而暂停的时候,被动让出自己的执行权利,可以保证最大化利用CPU的资源
- 被动调度依然是协程发起的操作,因此调度的实际相对明确
- 先切换为g0,取消G与M的绑定关系,但是不会将G放入全局队列,因为此时G的状态为
_Gwaiting
,因此需要一个额外的唤醒机制,gopark
函数内是核心逻辑 gopark
函数最后会调用park_m
,解除G和M之间的关系,将状态调整为_Gwaiting
,根据被动调度执行的原因的不同,执行不同的waitunlockf
函数,然后开始新一轮调度- 如果当前协程需要被唤醒,会先将
_Gwaiting
转换为Grunnable
,并添加到当前P的局部运行队列中
¶抢占调度
- Go在初始化的时候会有一个特殊的线程来执行系统监控任务,运行在独立M上,不用绑定逻辑处理器,每隔10ms会检测是否有准备就绪的网络协程,并放到全局队列中
- 该监控服务会判断当前协程是否运行时间过程,或者处于系统调用阶段,如果是就会抢占当前G的执行。如果当前协程的执行时间超过了10ms,或者在系统调用中超过了10ms,那么就需要被抢占调度
¶执行时间过长
- 1.14版本之前,调度发生的时机主要在执行函数调用阶段,但是比如在无限循环等特殊场景下,执行过程没有函数调用,就没有被强占的机会
- 1.14版本之后引入信号强制抢占,发送
sigPreempt
信号,相当于内核级别的_SIGURG
信号(类UNIX系统中)给线程,实现抢占,如图: - 处理信号时会调用抢占函数,修改原程序中的rsp和rip从而使内核态返回后执行新的函数
asyncPreempt2
来切换回调度循环。
¶系统调度时间过长
- 发生系统调用的时候,当前线程会陷入等待状态,等待系统调用完成。当发生如下情况时需要抢占调度
-
- 局部队列中有等待运行的G
-
- 没有过空闲的P和自旋的M
-
- 系统调用时间超过10ms
- 抢占时,会将P的状态转化为
_Pidle
,还需让M来接管P的运行。在以下情况中需要启动一个M来接管当前P -
- 局部运行队列中有等待运行的G
-
- 有gc后台任务处理
-
- 所有其他P都有G运行,没有自旋M
-
- 需要处理网络socket读写等事件
- 都不满足的时候,会将P放到空闲队列中
【Go笔记】深入协程设计与调度原理
https://study.0x535a.cn/go-note/go-gmp/