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的调度

相关推荐
Pandaconda2 天前
【操作系统】每日 3 题(十八)
linux·服务器·开发语言·数据结构·笔记·后端·操作系统
vincent_woo2 天前
再学安卓 - 系统环境安装
操作系统
Raymond运维2 天前
第一章 Linux安装 -- 安装Debian 12操作系统(四)
linux·运维·服务器·操作系统·debian
小蜗的房子2 天前
一篇文章让你了解Linux中的用户和组权限
linux·运维·服务器·后端·学习·操作系统·基础
简鹿办公3 天前
Windows 怎么关机?这五种方法你需要了解一下
操作系统
星海幻影3 天前
linux基础-完结(详讲补充)
linux·服务器·网络·安全·操作系统
小林up4 天前
【MIT-OS6.S081笔记1】Chapter1阅读摘要:Operating system interfaces
笔记·操作系统
linhhanpy4 天前
自制操作系统(九、操作系统完整实现)
c语言·开发语言·汇编·c++·操作系统·自制操作系统
tt5555555555555 天前
操作系统学习笔记-5.1-IO设备
服务器·笔记·嵌入式硬件·学习·操作系统
Pandaconda5 天前
【操作系统】每日 3 题(十六)
java·开发语言·笔记·后端·面试·职场和发展·操作系统