【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/
Author
Stephen Zeng
Posted on
August 30, 2025
Licensed under