Golang 协程调度原理

整体架构

Golang协程整体架构如图:

G-P-M架构

  • G:协程
  • M:操作系统的抽象对象;
  • P:逻辑处理器

执行过程基本是:一个M,绑定一个P后,进入调度执行,不断执行P获取到的G。

架构演进

G-M 模型

Golang在开始发布的时候采用了G-M模型

缺点:限制了Go并发程序的伸缩性

  1. 单一全局互斥锁(Sched.Lock)和集中状态存储 导致所有goroutine相关操作都需要上锁(比如创建、重新调度)
  2. goroutine传递问题,M之间传递可运行的G,导致调度延迟增大以及额外的性能损耗
  3. 每个M做内存缓存,导致内存占用过高,数据局部性较差
  4. 由于syscall调用而形成的剧烈的worker thread阻塞和解除阻塞,导致额外的性能损耗

G-P-M模型

计算机科学领域的任何问题都可以通过增加一个间接的中间层来解决。

在G-M之间加入一个中间层解决,P代表逻辑CPU。

缺点:不支持抢占式调度,导致一旦某个G中出现死循环或永久循环的代码逻辑,那么G将永久占用分配给它的P和M,位于同一个P中的其他G将得不到调度,出现“饿死”的情况

Go1.2实现抢占式调度

原理是在每个函数或方法的入口,加上一段额外的代码,让runtime有机会检查是否需要执行抢占调度

缺点:局部解决了“饿死”问题,对于没有函数调用,纯算法循环计算的G,scheduler依然无法抢占

NUMA调度模型

暂未实现

G-P-M 模型详细分析

要搞懂G-P-M模型的详细架构,那么要解决接下来的这些问题:

  • M的创建时间点?
  • P的创建时间点?
  • G的创建时间点?
  • G的调度方式,如何保证公平?

我们抱着这些疑问来详细分析。

首先,Golang程序是怎么启动的呢?启动时做了如下这些事情:

  1. 初始化固定数量的P(默认为cpu核心数)
  2. 创建一个新的G来启动runtime.main
  3. 创建全局 M0、全局 G0,启动 M0 进入第一个调度循环
  • 其中:
  • M0:代表第一个启动的M
  • GO:执行runtime调度,每个M都会绑定一个G0

首先,对于一个Golang的程序,最多可以并发的线程数,是初始化的P的个数。在设计上,通过P的数量限制并发的线程数。

第二点,在Golang中,首先启动的协程是通过runtime.main启动的,然后进行一些初始化和准备,最终调用main.main方法。

M

在哪些情况下回创建M呢?

  • 当P中存在没有绑定的M,且有需要支持的G时,则尝试绑定一个M(创建或者绑定已有)。

M的启动过程:

  1. 获取空闲的P
  2. 尝试获取空闲的M,如果没有则新建一个(新建M的时候,会创建一个一个G0)
  3. 将M和P绑定
  4. 启动,进入调度循环
    1. 从本地队列获取一个G,没有则从其他地方获取
      每处理一些任务之后,就优先从全局队列里获取任务,以保障公平性,防止由于每个P里的G过多,而全局队列里的任务一直得不到执行机会
    2. 执行G

P

P代表逻辑处理器。P在初始化时,根据CPU的核心数或者环境变量GOMAXPROCS(有最大限制),创建对应个数的P。

可以通过runtime.GOMAXPROCS()重新设置了最大 CPU 使用数量。

G

系统中一共有哪些G呢?

  1. gofunc创建创建的

    基本方式为:从当前P中找一个空闲的G,没有则新建一个;放入当前P的队列;唤醒M开始执行。

  2. epoll

    从内核中获取到一些事件,拿到了有收到就绪的 FD。再将对应的G唤醒标记为ready,同时将这些G放入全局的等待队列。

  3. 每个M绑定的G0

G是怎么创建并进入调度流程的呢?

首先何为调度:调度就是决定何时哪个goroutine将获得资源开始执行、哪个goroutine应该停止执行让出资源、哪个goroutine应该被唤醒恢复执行等。

G创建与进入调度流程如下:

  1. 从当前P中复用一个G
  2. 如果没有,则新建一个G
  3. 给G的执行环境里的 pc 变量赋值了一个 goexit 的函数地址,也就是说G正常执行完退出时执行的是 goexit 函数(切换到G0下释放G,并放置回P的本地队列中)
  4. 尝试将G添加到当前P的runnext中,作为下一个执行的G
  5. 否则放到Local队列runq中(无锁)
  6. 如果以上操作都失败,则添加到Global队列sched.runq中(有锁操作,因此也会顺便将当P.runq中一半的G转移到sched.runq)

那么当前协程如何让出CPU?

  1. G正常退出

    G中会指定一个退出函数,当退出时调用该函数(退出函数是,将函数栈切换到了G0,释放G)

  2. 主动让出

    即临时停止或阻塞,需要让出CPU(如time.sleep、IO阻塞等)(挂起当前G)(gopark进行调度让出CPU资源。切换到G0下,保存当前的G的函数栈等信息,并修改G的状态为等待(表明正在等待唤醒))。

  3. 主动抢占

    runtime.main中有一个监控任务,sysmon方法。

    超过10ms还在执行的G将会被抢占,只是做一个标记,实际抢占发生在栈扩张的时候(下个函数或方法调用时,判断是否栈不够,不够则扩张)(小函数优化:不进行校验)

    调用函数时,准确的说是在分配函数栈时抢占

  4. 系统调用让出

    G进入系统调用时,会保存上下文,标记为“系统调用状态”,等待被抢占走。系统调用退出时,则通过G0下将G切回来,如果有可执行的P,则执行,没有则放全局队列,等待调度。

上诉4种G让出CPU的方式。下面列出多种主动让出的场景:

  1. time.Sleep

    • 休眠:将计时器加入到timer管理器中,通过goparkunlock实现当前G的休眠
    • 唤醒:将休眠的G标记为可运行状态,并放入P的待运行队列中
  2. Mutex

    通过goparkunlock方法进入休眠,并加入到root.queue队列等待唤醒

  3. channel

    当给一个 chan 发送消息的时候,实质触发的方法是 chansend。在该方法里不是先进入休眠状态。

    1. 如果此时有接收者收到这个消息,则直接将通过send方法直接发送给接收者,并唤醒接收者G,当前发送者G继续执行
    2. 如果没有接收者,将数据copy到chan的临时内存中,且内存没有满则继续执行该G
    3. 如果没有接收者且chan满了,通过goparkunlock进入休眠。休眠前把当前的G相关信息存到队列(sendq)以便有接收者接收数据的时候唤醒当前G。

    唤醒发送者:

    • 如果发送者被休眠,则取出数据然后唤醒发送者,当前接收者的G拿到数据继续执行
    • 如果没有休眠的发送者,则看一下是否有已经发送的数据没有被接收,有则直接取数据继续执行(直接从chan的内存取)
    • 如果既没有休眠的发送者,chan中也没有数据,则通过goparkunlock休眠,放入recvq队列中,等待唤醒

主动抢占中提到,通过监控任务sysmon来执行监控工作,协助抢占。那么sysmon究竟有哪些作用呢?

sysmon是一个由runtime启动的M,监控线程,无需P也可以运行,每20us~10ms唤醒一次

sysmon作用

  • 释放闲置超过5分钟的span物理内存
  • 如果超过2分钟没有垃圾回收,强制执行
  • 将长时间未处理的netpoll结果添加到任务队列
  • 向长时间运行的G任务发出抢占调度
  • 收回因syscall长时间阻塞的P

主动抢占发生在函数栈调用时,准确讲为栈扩张的时候。那么栈何时扩张,以及扩张是一个怎么样的流程?

栈扩张:基本过程就是分配一个2x大小的新栈, 把数据拷贝到新栈,并用新栈替换到旧栈。栈缩容:由垃圾回收器在垃圾回收时主动触发的。基本过程是计算当前使用的空间,小于栈空间的1/4的话, 执行栈的收缩,将栈收缩为现在的1/2,否则直接返回。

在 runtime 下会启动一个全程运行的监控任务,该任务用于标记抢占执行过长时间的G,以及检测 epoll 里面是否有可执行的G。

netpoll是Go针对网络IO的一种优化,本质上为了避免网络IO陷入系统调用之中,这样使得即便G发起网络I/O操作也不会导致M被阻塞(仅阻塞G),从而不会导致大量M被创建出来。

G的几种暂停方式:

  1. gosched: 暂停当前G,保存状态并将G设置为可运行状态放入Global队列,当前M继续执行。
  2. gopark: 暂停后,设置为等待状态,放入专门的等待队列
  3. notesleep: 既不让出M,也不让G与P重新调度,直接让线程休眠直到唤醒(notewakeup),该方式更快,通常用于gcMark,stopm这类自旋场景