Golang之GMP调度模型

179 阅读12分钟

肝不动了:

Golang 的 goroutine 是如何实现的? - 知乎 (zhihu.com)

Scheduling In Go : Part II - Go Scheduler (ardanlabs.com)

浅析Golang的线程模型与调度器 - 3WLineCode - 博客园 (cnblogs.com)

详解 Go 程序的启动流程,你知道 g0,m0 是什么吗? - 掘金 (juejin.cn)

subroutine/coroutine/goroutine

subroutine(子程序)

就是函数。也称为函数或方法,是一段封装了特定功能的可重复调用的代码块。子程序通过接受参数和返回结果来完成特定的任务。

coroutine(协程)

协程(Coroutine),也称为轻量级线程,是一种可以暂停和恢复执行的函数。与常规的函数不同,协程具有特殊的执行控制流,并且可以在不阻塞线程的情况下进行切换和调度,以实现高效的并发编程。

协程的特征包括:

  1. 可暂停和恢复:协程可以在运行过程中暂停自己的执行,并在需要时继续执行,而不是像常规函数一样只能从头开始执行。这使得协程可以按需挂起和恢复,从而有效地利用计算资源。
  2. 非抢占式调度:协程的调度是由程序员控制的,而不是操作系统或调度器强制性地进行的。协程会主动让出执行权给其他协程,而不是被迫中断,从而避免了线程切换的开销并提高了执行效率。
  3. 局部状态:协程可以持有自己的局部状态,在暂停和恢复时保留状态信息。这使得协程之间可以独立地保存和修改自己的局部变量,而不会相互干扰。
  4. 协作式多任务处理:协程通常通过协作式方式来实现多任务处理。多个协程之间可以相互合作,配合完成任务,并通过协程调度器进行协调和切换。这样可以实现高效的并发编程,避免了多线程或多进程中的锁、同步等问题。

总结来说,协程是一种轻量级的、可暂停和恢复的函数,具备非抢占式调度和独立的局部状态特点。它在并发编程中可以实现高效的任务切换和协作处理,提供了一种灵活而强大的编程模型。

  • 对称协程 Symmetric Coroutine:任何一个协程都是相互独立且平等的,调度权可以在任意协程之间转移。
  • 非对称协程 Asymmetric Coroutine:协程出让调度权的目标只能是它的调用者,即协程之间存在调用和被调用关系。
  1. 有栈协程(Stackful Coroutines):

    • 有栈协程与线程类似,每个协程都有自己的调用栈,用于保存局部变量和函数调用信息。
    • 在切换协程时,需要保存和恢复整个调用栈的状态,包括栈指针、局部变量等。
    • 有栈协程通常具有更高的灵活性和功能丰富性,可以支持递归调用、动态分配栈空间等。
    • 由于需要保存完整的调用栈状态,切换开销相对较大,可能会影响性能。
  2. 无栈协程(Stackless Coroutines):

    • 无栈协程并不直接使用独立的调用栈,而是通过轻量级的状态机来实现协程的切换。
    • 每个协程的状态和局部变量等信息都被封装在结构体中,切换时保存和恢复这些状态即可。
    • 无栈协程通常具有更低的内存消耗和切换开销,适用于大规模并发的场景。
    • 由于没有专门的调用栈,无栈协程可能有一些功能上的限制,如不支持递归调用等。

协程(Coroutine)和操作系统线程在以下几个方面存在区别:

  1. 调度方式:操作系统线程的调度是由操作系统内核完成的,它使用抢占式调度策略,即操作系统可以强制中断线程并切换到其他线程执行。而协程的调度是由程序员控制的,它采用非抢占式调度策略,即协程在自愿让出执行权之前不会被中断,需要调度器显式地进行切换。
  2. 切换开销:由于操作系统线程是由操作系统内核管理和调度的,线程之间的切换存在一定的开销。涉及上下文切换、寄存器保存和恢复等操作。而协程的切换是在用户空间进行的,相对于线程切换,协程切换的开销较小,因为它只需要保存和恢复协程的局部状态,不涉及内核态与用户态的切换。
  3. 并发性:在传统的多线程编程中,线程是并发执行的,每个线程都有自己的执行上下文和堆栈,因此可以利用多核处理器的硬件并行能力。而协程并不能直接利用多核处理器的并行能力,因为协程是在单个线程内部切换执行的。协程通过在单个线程中进行快速的切换和调度,可以实现高效的并发编程,但本质上仍然是单线程并发
  4. 共享状态:操作系统线程共享同一个进程的地址空间,因此多个线程之间可以方便地共享数据和状态。而协程通常基于生成器(Generator)或异步编程模型来实现,不同协程之间可以有各自独立的局部状态,不能像线程那样直接共享数据,需要通过显式的消息传递或其他同步机制进行通信。

协程和操作系统线程在调度方式、切换开销、并发性和共享状态等方面存在差异。协程通过非抢占式调度、低开销的切换、高效的并发控制和独立的局部状态,提供了一种更加灵活和高效的并发编程方式。

goroutine

Goroutine是Go语言中的轻量级并发执行单元,由Go运行时来进行调度和管理。它提供了一种简洁而高效的方式来实现并发,并充分利用多核处理器的能力。通过Goroutine和通道,可以编写出安全、高效的并发程序。

goroutine和有栈协程有很多相似性,都是由用户态进行管理调度;都有自己的栈空间,创建、销毁、切换的开销都很小,底层原理都是复用操作系统的原生线程。但是也有所区别:

goroutine的调度是由go语言运行时实现的抢占式调度,与操作系统原生线程是M:N的比例模型,可以在不同的线程间切换,从而能够利用多核的能力,兼具协程和线程的优点;

coroutine协程调度是协作式调度,由程序员控制何时何处主动让出CPU执行权,与操作系统原生线程是M:1的比例模型,不能在不同的线程间切换,多核能力需要多线程配合使用。

G、M与KSE

image.png

G代表的goroutine由用户态进行调度,M对应的是操作系统的内核态KSE(Kernel Schedule Entity,内核可调度实体),在Linux下即为clone系统调用所创建的LWP(Light-weight Process,轻量级进程)。由于M的创建销毁切换的开销要远大于G,所以GMP模型中会尽量避免M频繁的创建、销毁和切换。

P与GM模型

image.png

在go1.0版本并没有P,只有G和M。待运行的G处于一个全局的G队列中,G的运行、暂停、切换、结束,都涉及G和M的绑定、解绑,goroutine相关信息的栈和寄存器信息的保存和恢复。 全局G队列需要加锁进行保护。因此,goroutine的执行和切换都存在性能损耗。

在后续版本引入了P,goroutine除了挂在全局队列上也可以挂在本地P队列上。M和P进行绑定从而执行G。新建的G具有亲和性,会优先加入原goroutine所在的P队列。M在本地P队列切换执行不同的G是不需要加锁的;由于G和P的亲和性,两个父子关系的G由M顺次执行时,也可以省去goroutine切换的开销;P的数量是固定的,其值为GOMAXPROCS,程序启动时即创建,默认情况下,该值是主机的CPU核心数。代表物理机器的真正并行度,P的个数与CPU核心数的关系使得程序可以充分利用物理主机的多核并行能力,最大化性能。 image.png

golang的调度启动流程

  1. 程序启动时,执行汇编代码创建第一个G,赋值给g0
  2. 执行汇编代码创建第一个M,即主线程,复制给m0,将m0和g0互相绑定
  3. 创建并初始化GOMAXPROCS个P,将m0与某个P进行绑定
  4. 创建第二个G,即 main goroutine,绑定runtime.main(内部将执行main包的main方法)方法,加入m0的P的本地runq队列
  5. m0作为一个普通的M执行g0进入调度,调度m0执行所绑定的P的runq上的G,也就是main goroutine,也就是执行runtime.main方法
  6. runtime.main创建第二个M成为后台监控线程sysmon,sysmon不会与P进行绑定,也不会退出,是一个死循环,负责处理网络数据、抢占 P/G、触发 GC、清理堆 span的工作。
  7. runtime.main执行到main包的main方法

G和M的创建

  1. 进入main方法后,开始执行程序员编写的代码,可采用go关键字来创建新的G;G和P具有亲和性,优先会挂在创建它的老G绑定的P的本地runq队列上
  2. 当老G绑定的P的本地runq队列已满,新G会挂在全局runq队列上;全局队列是有锁的,从全局runq获取或者释放G需要加解锁
  3. go 程序启动时,会设置 M 的最大数量,默认 10000;一个M阻塞了,P和M可能会解绑,切换或者创建新的M来执行P上的G

G的转移

  1. 新建的G优先挂在创建它的老G所绑定的P的本地runq队列上(P的亲和性)
  2. P的本地runq队列最大长度为256;当本地runq队列满时,新的G会被挂在全局runq队列上
  3. M和P绑定后就会执行P上的G,当P上没有G时,会去全局runq队列上获取最多32个G放入本地队列
  4. 当全局runq队列中也没有G时,则会从其他某个P窃取一半的G放入当前P的本地runq队列

G和M的阻塞

  1. 同步系统调用(比如文件IO操作)导致G阻塞:此时也会导致M的阻塞,因此G和M都与P解绑,GM不会解绑,P寻找新的M来执行其余的G;当系统调用完成时,GM解绑,M进入空闲,G重新回到P的本地runq队列
  2. 原子操作、互斥量、通道等导致G阻塞:此时M不会阻塞,M和G解绑,M切换到P的本地runq队列的下一个G执行
  3. 网络IO导致G阻塞:此时M不会阻塞,G和M,G和P解绑,G挂在网络轮询器上执行网络IO操作,M则切换到P的本地runq队列的下一个G执行,G执行完IO操作重新回到P的本地runq队列
  4. sleep等操作导致G阻塞:后台有监控线程 sysmon监控那些长时间运行的 G设置可以强占的标识符,别的 G就可以抢过M来执行,当前的G则与M解绑放回P的本地runq队列

后台监控线程sysmon参与调度

sysmon的作用:

  • 管理由应用程序创建的计时器 (timers)的执行: sysmon会检查应该在运行却仍在等待执行时间的计时器,查看空闲的 M 和 P 列表, 尽快地运行它们
  • 检查网络轮询器和系统调用中的G:运行在网络操作中被阻塞的G
  • 强制执行垃圾回收:如果垃圾回收器已经两分钟没有运行,则 sysmon 将强制执行一轮垃圾回收 (GC)。
  • 长时间运行的G的抢占:任何运行时间超过10毫秒的G都会被抢占,将运行时间留给其他的G

sysmon每20us~10ms执行一次循环,检查所有运行超过10ms的G,设置可以被抢占的标识符进行抢占式调度,将这些G切换出去,让其他的G可以运行;当所有的M都处于空闲状态或者垃圾回收器即将运行时,sysmon会休眠一轮

STW(stop the world)

当垃圾回收器触发时,它会发送一个信号给所有的 M(操作系统线程),要求它们停止执行。M 在接收到信号后会主动将自己的状态设置为阻塞状态,所有的M和各自的P分离,放入空闲队列,并等待垃圾回收器的下一步操作。完成垃圾回收算法后,垃圾回收器会解除对所有 M 的阻塞,调度器恢复调度,使得 Goroutine 可以继续执行。