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

相关推荐
望获linux18 小时前
【实时Linux实战系列】实时系统的调试技术
linux·运维·服务器·网络·数据库·操作系统·嵌入式软件
DoraBigHead19 小时前
进程的内存是怎么分的?深入解析内存管理的秘密工事
操作系统
望获linux19 小时前
【Linux基础知识系列】第五十四篇 - 网络协议基础:TCP/IP
java·linux·服务器·开发语言·架构·操作系统·嵌入式软件
、BeYourself1 天前
死锁的避免
操作系统
CYRUS_STUDIO2 天前
深入 Android syscall 实现:内联汇编系统调用 + NDK 汇编构建
android·操作系统·汇编语言
LUCIAZZZ3 天前
高性能网络模式-Reactor和Preactor
java·服务器·开发语言·网络·操作系统·计算机系统
喧星Aries3 天前
内存的基础相关知识,什么是内存,内存管理
操作系统·内存·计算机组成原理
DoraBigHead3 天前
进程之间怎么聊天?信号、管道、共享内存、Socket 大乱斗!
操作系统
喧星Aries3 天前
进程的内存映像,只读区,可读写区,堆,共享库,栈详解
linux·操作系统·计算机组成原理
半桔3 天前
【Linux手册】重定向是如何实现的?Linux下为什么一切皆文件?
linux·运维·服务器·面试·系统架构·操作系统