GO的并发调度模型-GMP模型

前身 GM 模型

12年的 GO 1.1版本之前用的都是 GM 模型,但是由于 GM 模型性能不好,饱受用户诟病。之后官方对调度器进行了改进,变成了我们现在用的 GMP 模型。

GM 模型中的 G 全称为 Goroutine 协程, M 全称为 Machine 内核级线程,调度过程如下: M (内核线程)从加锁的 Goroutine 队列中获取 G (协程)执行,如果 G 在运行过程中创建了新的 G ,那么新的 G 也会被放入全局队列中。

M 想要执行、放回 G 都必须访问全局 G 队列,并且 M 有多个,即多线程访问同一资源需要加锁进行保证互斥 / 同步,所以全局 G 队列是有互斥锁进行保护的。

老调度器有几个缺点:

  1. 创建、销毁、调度 G 都需要每个 M 获取锁,这就形成了激烈的锁竞争。
  2. M1 转移 G1 会造成延迟和额外的系统负载。比如当 G1 中包含创建新协程的时候,M1 创建了 G2,为了继续执行 G1 ,需要把 G2 交给 M2 执行,也造成了很差的局部性,因为 G2和 G1 是相关的,最好放在 M1 上执行,而不是其他 M2。
  3. 系统调用 (CPU 在 M 之间的切换) 导致频繁的线程阻塞和取消阻塞操作增加了系统开销。

GMP 模型

G 全称为 Goroutine 协程, M 全称为 Machine 内核级线程,P全称为 Processor 协程运行所需的资源,在 GM 模型的基础上增加了一个 P 层,来管理两种存放 G 的队列。

GMP模型和GM模型的区别

  • 每个 P 有自己的本地队列,而不是所有的G操作都要经过全局的 G 队列,这样锁的竞争会少的多的多。而 GM 模型的性能开销大头就是锁竞争。
  • P的本地队列平衡上,在 GMP 模型中也实现了 Work Stealing 算法,如果 P 的本地队列为空,则会从全局队列或其他 P 的本地队列中窃取可运行的 G 来运行(通常是偷一半),减少空转,提高了资源利用率。
  • hand off 机制当 M1 线程因为 G1 进行系统调用阻塞时,线程释放绑定的 P1 ,把 P1 转移给其他空闲的线程 M2 执行,同样也是提高了资源利用率。

调度设计

  • 复用线程:避免频繁的创建、销毁线程,而是对线程的复用

    • work stealing 机制 当本线程无可运行的 G 时,尝试从其他线程绑定的 P 偷取 G,而不是销毁线程
    • hand off 机制 当本线程因为 G 进行系统调用阻塞时,线程释放绑定的 P,把 P 转移给其他空闲的线程执行
  • 利用并行:GOMAXPROCS 设置 P 的数量,最多有 GOMAXPROCS 个线程分布在多个 CPU 上同时运行。GOMAXPROCS 也限制了并发的程度,比如 GOMAXPROCS = 核数/2,则最多利用了一半的 CPU 核进行并行。

  • 抢占:在 coroutine 中要等待一个协程主动让出 CPU 才执行下一个协程,在 Go 中,一个 goroutine 最多占用 CPU 10ms,防止其他 goroutine 被饿死,这就是 goroutine 不同于 coroutine 的一个地方。

  • 全局 G 队列:在新的调度器中依然有全局 G 队列,但功能已经被弱化了,当 M 执行 work stealing 从其他 P 偷不到 G 时,它可以从全局 G 队列获取 G。

调度器的生命周期

  1. runtime 创建最初的线程 m0 和 goroutine g0,并把 2 者关联。
  2. 调度器初始化:初始化 m0、栈、垃圾回收,以及创建和初始化由 GOMAXPROCS 个 P 构成的 P 列表。
  3. 示例代码中的 main 函数是 main.main,runtime 中也有 1 个 main 函数 ------runtime.main,代码经过编译后,runtime.main 会调用 main.main,程序启动时会为 runtime.main 创建 goroutine,称它为 main goroutine 吧,然后把 main goroutine 加入到 P 的本地队列。
  4. 启动 m0,m0 已经绑定了 P,会从 P 的本地队列获取 G,获取到 main goroutine。
  5. G 拥有栈,M 根据 G 中的栈信息和调度信息设置运行环境
  6. M 运行 G
  7. G 退出,再次回到 M 获取可运行的 G,这样重复下去,直到 main.main 退出,runtime.main 执行 Defer 和 Panic 处理,或调用 runtime.exit 退出程序。

调度过程

P 的队列

  • 全局队列:当 P 中的本地队列中有协程 G 溢出时,会被放到全局队列中
  • 本地队列: P 内置的 G 队列,存的数量有限,不超过256个
  • wait队列:为io阻塞就绪态goroutine队列 队列处理的逻辑:
  • 当队列 P1 中的 G1 在运行过程中新建 G2 时, G2 优先存放到 P1 的本地队列中,如果队列满了,则会把 P1 队列中一半的G移动到全局队列
  • 当P的本地队列为空时,会先到全局队列中获取 G ,如果全局队列中也没有 G ,则会尝试从其他线程绑定的P中偷取一半的 G (即任务窃取

P 和 M

  • P 的创建
    在确定了 P 的最大数量n 后,运行时系统会根据这个数量创建n 个 P
  • M 何时创建
    内核级线程的初始化是由内核管理的,当没有足够的 M 来关联 P 并运行其中的可运行的 G 时会请求创建新的 M 。比如M在运行 G1 时被阻塞住了,此时需要新的 M 去绑定 P ,如果没有在休眠的M则需要新建 M

G 的流动过程

  1. 调用 go func() 创建一个 goroutine
  2. 新创建的 G 优先保存在 P 的本地队列中,如果 P 的本地队列已经满了 G 就会保存在全局的队列中
  3. 如果 P 的本地队列为空,则先会去全局队列中获取 G ,如果全局队列也为空则去其他 P 中偷取 G 放到自己的 P 中
  4. M 会优先从在 P 的本地队列中获取一个可执行的 G ,其次取全局队列,最后取wait队列
  5. G 将相关参数传输给 M ,为 M 执行 G 做准备
  6. 当 M 执行某一个 G 时候如果发生了系统调用产生导致 M 会阻塞,如果当前 P 队列中有一些 G , runtime 会将线程 M 和 P 分离,然后再获取空闲的线程或创建一个新的内核级的线程来服务于这个 P ,阻塞调用完成后 G 被销毁将值返回
  7. 销毁 G ,将执行结果返回
  8. 当 M 系统调用结束时候,这个 M 会尝试获取一个空闲的 P 执行,如果获取不到 P ,那么这个线程 M 变成休眠状态, 加入到空闲线程中

数据结构

G 的数据结构

go 复制代码
type g struct {
    // ...
    m         *m      
    // ...
    sched     gobuf
    // ...
}


type gobuf struct {
    sp   uintptr
    pc   uintptr
    ret  uintptr
    bp   uintptr // for framepointer-enabled architectures
}
  1. m:在 p 的代理,负责执行当前 g 的 m
  2. sched.sp:保存 CPU 的 rsp 寄存器的值,指向函数调用栈栈顶
  3. sched.pc:保存 CPU 的 rip 寄存器的值,指向程序下一条执行指令的地址
  4. sched.ret:保存系统调用的返回值
  5. sched.bp:保存 CPU 的 rbp 寄存器的值,存储函数栈帧的起始位置.

M 的数据结构

go 复制代码
type m struct {
    g0      *g     // goroutine with scheduling stack
    // ...
    tls           [tlsSlots]uintptr // thread-local storage (for x86 extern register)
    // ...
}
  1. g0:一类特殊的调度协程,不用于执行用户函数,负责执行 g 之间的切换调度,与 m 的关系为 1:1。g0 也叫系统堆栈,runtime通常使用systemstackmcallasmcgocall临时切换到系统堆栈,以执行必须不被抢占的任务、不得增加用户堆栈的任务或切换用户goroutines。在系统堆栈上运行的代码隐式不可抢占,垃圾收集器不扫描系统堆栈。在系统堆栈上运行时,不会使用当前用户堆栈执行。
  2. tls:thread-local storage,线程本地存储,存储内容只对当前线程可见。 线程本地存储的是 m.tls 的地址,m.tls[0] 存储的是当前运行的 g,因此线程可以通过 g 找到当前的 m、p、g0 等信息。因此,TLS 代表每个线程中的本地数据。写入 TLS 中的数据不会干扰到其余线程中的值。 Go 操作 TLS 会使用系统原生的接口,以Linux X64为例,go在新建M时候会调用 arch_prctl 这个syscall来设置FS寄存器的值为M.tls的地址,运行中每个M的FS寄存器都会指向它们对应的M实例的tls, linux内核调度线程时FS寄存器会跟着线程一起切换,这样go代码只需要访问FS寄存器就可以获取到线程本地的数据。

P 的数据结构

go 复制代码
type p struct {
    // ...
    runqhead uint32
    runqtail uint32
    runq     [256]guintptr
    
    runnext guintptr
    // ...
}
  1. runq:本地 goroutine 队列,最大长度为 256
  2. runqhead:队列头部
  3. runqtail:队列尾部
  4. runnext:下一个可执行的 goroutine

## schedt的数据结构

go 复制代码
type schedt struct {
    // ...
    lock mutex
    // ...
    runq     gQueue
    runqsize int32
    // ...
}

sched 是全局 goroutine 队列的封装:

  1. lock:一把操作全局队列时使用的锁
  2. runq:全局 goroutine 队列
  3. runqsize:全局 goroutine 队列的容量

参考资料

深入golang runtime的调度

相关推荐
阑梦清川14 小时前
linux操作系统课程学习02
操作系统
阑梦清川14 小时前
linux操作系统课程学习01
操作系统
望获linux3 天前
【实时Linux实战系列】CPU 隔离与屏蔽技术
java·linux·运维·服务器·操作系统·开源软件·嵌入式软件
数据智能老司机4 天前
Linux内核编程——网络驱动程序
linux·架构·操作系统
数据智能老司机4 天前
Linux内核编程——字符设备驱动程序
linux·架构·操作系统
数据智能老司机4 天前
Linux内核编程——Linux设备模型
linux·架构·操作系统
望获linux4 天前
【Linux基础知识系列】第四十篇 - 定制彩色终端与 Prompt
linux·运维·前端·chrome·操作系统·开源软件·嵌入式软件
望获linux14 天前
【实时Linux实战系列】实时I/O操作与中断处理
linux·服务器·microsoft·操作系统·交互·rtos·嵌入式软件
redreamSo14 天前
世俗点,假如幸福能量化,公式是什么?
操作系统
智践行14 天前
ROS2 Jazzy:编写可组合节点(C++)
操作系统