Go GMP调度模型详解

一、概念由浅入深,先说并发/并行:

并发: 逻辑上具有处理多个同时性任务的能力。

并行: 物理上同一时刻执行多个并发任务。

我们通常所说的并发编程,就是说它允许多个任务同时执行,但实际上并不一定在同一时刻被执行。在单核处理器上,通过多线程共享CPU时间片串行执行(并发非并行)。而并行则依赖于多核处理器等物理资源,让多个任务可以实现并行执行(并发且并行)。

多线程或多进程是并行的基本条件,但单线程也可以用协程(coroutine)做到并发。简单将 Goroutine 归纳为协程并不合适,因为它运行时会创建多个线程来执行并发任务,且任务单元可被调度到其它线程执行。这更像是多线程和协程的结合体,能最大限度提升执行效率,发挥多核处理器能力。

二、GoLang 协程优势在哪里?

1、先了解一个基础概念叫上下文切换

上下文切换的流程如上图:

  • CPU切换前把当前任务的状态保存下来,以便下次切换回这个任务时可以再次加载这个任务的状态,然后加载下一任务的状态并执行。任务的状态保存及再加载, 这段过程就叫做上下文切换。
  • 挂起当前任务(线程/进程),将这个任务在 CPU 中的状态(上下文)存储于内存中的某处;恢复一个任务(线程/进程),在内存中检索下一个任务的上下文并将其在 CPU 的寄存器中恢复;跳转到程序计数器所指向的位置(即跳转到任务被中断时的代码行),以恢复该进程在程序中
  • 一个保持可运行状态的进程通常被绑定在一个固定的cpu上。内核周期性的检查运行队列(在一个cpu核心上)的工作量是否平衡,并在需要的时候把一些进程从一个运行队列迁移到另一个运行队列。
2、第二组概念,用户态和内核态:

出于安全的考虑:在操作系统中有一些较危险指令,应交由受信任的内核来完成。当程序执行时不涉及访问硬件资源,便处于用户态,当程序主动发起系统调用想要访问硬件资源时,产生异常时或者外部硬件产生中断时,便会进入内核态。结合CPU特权级理解,用户态进行的操作是受限的,而内核态的操作是不受限的。

用户栈到内核栈的转换大体步骤:

  • 首先找到内核栈的栈基址和栈顶指针。
  • 将当前环境的各种状态值压入内核栈。
  • 将先前由中断向量检索得到的中断处理程序的 cs,ip 信息装入相应的寄存器,开始执行中断处理程序,这时就转到了内核态的程序执行了。(这里的“中断”指的是广义的中断,包括异常和系统调用)
3、进程、线程、协程

进程 是具有一定独立功能的程序关于某个数据集合上的一次运行活动,进程是系统进行资源分配和调度的一个独立单位。每个进程都有自己的独立内存空间,不同进程通过进程间通信来通信。由于进程比较重量,占据独立的内存,所以上下文进程间的切换开销(栈、寄存器、虚拟内存、文件句柄等)比较大,但相对比较稳定安全。

线程 是指进程内的一个执行单元,也是进程内的可调度实体。线程是进程的一个实体,是CPU调度和分派的基本单位,它是比进程更小的能独立运行的基本单位。线程自己基本上不拥有系统资源,只拥有一点在运行中必不可少的资源(如程序计数器,一组寄存器和栈),但是它可与同属一个进程的其他的线程共享进程所拥有的全部资源。线程间通信主要通过共享内存,相对进程来说上下文切换很快,资源开销较少,但不够稳定容易丢失数据。线程的最小栈为2MB。

  • 进程和线程的上下文切换,需要内核再CPU上进行切换,需要系统调用访问硬件资源,就会触发用户态和内核态之间的切换。
  • 线程上下文切换大约产生1000ns延时,需执行12k的CPU指令。

协程 是一种 用户态的轻量级线程 ,协程的调度完全由用户控制。从技术的角度来说,协程就是你可以暂停执行的函数。协程拥有自己的寄存器上下文和栈。协程调度切换时,将寄存器上下文和栈保存到其他地方,在切回来的时候,恢复先前保存的寄存器上下文和栈,直接操作栈则基本没有内核切换的开销,可以不加锁的访问全局变量,所以上下文的切换非常快。协程的最小栈为2KB。

  • 协程上下文切换大约产生200ns延时,需执行2.4k的CPU指令。
  • 通过用户态的上下文切换,将操作系统级别的阻塞 IO 工作变成了 CPU 密集型工作,可以更好的利用 CPU 时间片,减少线程级别的上下文切换。

协程与线程的区别:

  • 一个线程可以多个协程,一个进程也可以单独拥有多个协程。

  • 线程进程都是同步机制,而协程则是异步。

  • 协程能保留上一次调用时的状态,每次过程重入时,就相当于进入上一次调用的状态。

  • 线程是抢占式,而协程是非抢占式的,所以需要用户自己释放使用权来切换到其他协程,因此同一时间其实只有一个协程拥有运行权,相当于单线程的能力。

  • 协程并不是取代线程, 而是抽象于线程之上, 线程是被分割的CPU资源, 协程是组织好的代码流程, 协程需要线程来承载运行, 线程是协程的资源, 但协程不会直接使用线程, 协程直接利用的是执行器(Interceptor), 执行器可以关联任意线程或线程池, 可以使当前线程, UI线程, 或新建新程。

  • 线程是协程的资源。协程通过Interceptor来间接使用线程这个资源。

三、GMP模型

【参考:深入Golang调度器之GMP模型

1、名词概念
  • G对象:是 Goroutine 的缩写,相当于操作系统中的进程控制块,在这里就是 Goroutine 的控制结构,是对 Goroutine 的抽象。其中包括执行的函数指令及参数;G 保存的任务对象;线程上下文切换,现场保护和现场恢复需要的寄存器(SP、IP)等信息。

    • Go不同版本Goroutine默认栈大小不同。1.11 开始默认为2KB
  • M 是一个线程或称为 Machine,所有M是有线程栈的。如果不对该线程栈提供内存的话,系统会给该线程栈提供内存(不同操作系统提供的线程栈大小不同)。当指定了线程栈,则 M.stack→G.stack,M的PC寄存器指向G提供的函数,然后去执行。

  • P Processor 是一个抽象的概念,并不是真正的物理CPU。所以当P有任务时需要创建或者唤醒一个系统线程来执行它队列里的任务。所以P/M需要进行绑定,构成一个执行单元。

    P 决定了同时可以并发任务的数量,可通过 GOMAXPROCS 限制同时执行用户级任务的操作系统线程。可以通过runtime.GOMAXPROCS 进行指定。在 Go1.5 之后 GOMAXPROCS 被默认设置可用的核数,而之前则默认为1。

2、GMP 调度流程:

  • Go 调度器中有两种不同的运行队列:全局运行队列(GRQ)和本地运行队列(LRQ),每个 P 都有一个本地队列
  • 本地队列存放了等待执行的 Goroutine。

调度流程:

  • 首先创建一个G对象,G对象保存到P本地队列或者是全局队列。P此时去唤醒一个M。P继续执行它的执行序。

  • M寻找是否有空闲的P,如果有则将该G对象移动到它本身。接下来M执行一个调度循环:调用G对象->执行->清理线程→继续找新的Goroutine执行。

  • M执行过程中,随时会发生上下文切换。当发生上线文切换时,需要对执行现场进行保护,以便下次被调度执行时进行现场恢复。

  • Go调度器M的栈保存在G对象上,只需要将M所需要的寄存器(SP、PC等)保存到G对象上就可以实现现场保护。当这些寄存器数据被保护起来,就随时可以做上下文切换了,在中断之前把现场保存起来。如果此时G任务还没有执行完,M可以将任务重新丢到P的任务队列,等待下一次被调度执行。当再次被调度执行时,M通过访问G的vdsoSP、vdsoPC寄存器进行现场恢复(从上次中断位置继续执行)。

关于 P队列: 通过上图可以发现,P有两种队列:本地队列和全局队列。

  • 本地队列: 当前P的队列,本地队列是 Lock-Free ,没有数据竞争问题,无需加锁处理,可以提升处理速度。
  • 全局队列:全局队列为了保证多个P之间任务的平衡。所有M共享P全局队列,为保证数据竞争问题,需要加锁处理。相比本地队列处理速度要低于全局队列。
  • 工作窃取:当本地队列的G全部执行完成后,会从其他未完成的P本地队列或者全局队列获取G进行执行。M 从 P 中获取 G 的顺序:本地队列 P > 全局队列 P > 偷别人的 P 队列,具体流程如下。
    ① M0 首先从本地队列 P 中获取 G(无锁操作)
    ② 如果本地队列 P 为空,则从全局队列 P 获取G(加锁操作)
    ③ 如果全局队列 P 为空,再去另一个本地队列 P 采用 work-stealing 算法偷取一半数量的 G。

关于线程清理:

Goroutine 被调度执行必须保证P/M进行绑定,所以线程清理只需要将P释放就可以实现线程的清理。P被释放主要有两种情况。

  • 主动释放:最典型的例子是,当执行G任务时有系统调用,当发生系统调用时M会处于Block状态。调度器会设置一个超时时间,当超时时会将P释放。
  • 被动释放:如果发生系统调用,有一个专门监控程序,进行扫描当前处于阻塞的P/M组合。当超过系统程序设置的超时时间,会自动将P资源抢走。去执行队列的其它G任务。

3、GMP模型的前身 GM模型

  • 创建、销毁、调度 G 都需要每个 M 获取锁,这就形成了激烈的锁竞争。

  • M 转移 G 会造成延迟和额外的系统负载。比如当 G 中包含创建新协程的时候,M 创建了 G’,为了继续执行 G,需要把 G’交给 M’执行,也造成了很差的局部性,因为 G’和 G 是相关的,最好放在 M 上执行,而不是其他 M’。

  • 系统调用 (CPU 在 M 之间的切换) 导致频繁的线程阻塞和取消阻塞操作增加了系统开销。

【参考【Go 精选】从 GM 到 GMP 模型

4、NetPoller:GMP的网络调用

如图,当 G1 发生网络请求是,G1 并不会阻塞M,也不会因为阻塞导致创建新的 M,M 可以继续处理接下来的 G2

当产生网络请求时,这个 G1 会被挂在 netpoller 上,这样 M 就可以继续处理剩下的 G

网络事件发生时,比如 epoll_wait 通知有网络包可以读取时,这个时候 G1 会被重新放回P的本地队列

5、阻塞操作和同步调用

如图,当G1 发生系统调用,比如读取文件时,这是一个同步系统调用,会发生阻塞,

因为 G1 产生了阻塞,所以 M1 和 P 解绑,调度器引用了一个 M2 线程和 P 绑定,继续处理本地队列中的其他 G

这里可以发现类似 fopen 的阻塞操作,会使用更多的线程,无法和网络请求一样利用多路复用的特性将 G1 挂在netpoller 的线程上

最后当阻塞操作结束后,G1 会被继续放在 P 的本地队列中,M1 则放入线程池,以作备用