GMP

Go 语言的协程 goroutine
Go 为了提供更容易使用的并发方法, 使用了 goroutine 和 channel。goroutine 来自协程的概念, 让一组可复用的函数运行在一组线程之上, 即使有协程阻塞, 该线程的其他协程也可以被 runtime 调度, 转移到其他可运行的线程上。最关键的是, 程序员看不到这些底层的细节, 这就降低了编程的难度, 提供了更容易的并发。

Go 中, 协程被称为 goroutine, 它非常轻量, 一个 goroutine 只占几 KB, 并且这几 KB 就足够 goroutine 运行完, 这就能在有限的内存空间内支持大量 goroutine, 支持了更多的并发。虽然一个 goroutine 的栈只占几 KB, 但实际是可伸缩的, 如果需要更多内容, runtime 会自动为 goroutine 分配。

Goroutine 特点:

占用内存更小(几 kb)
调度更灵活 (runtime 调度)

goroutine 基于GMP调度模型完成

设计原理

单线程调度器 0.x

调度器只包含表示 Goroutine 的 G(协程) 和表示线程的 M(线程) 两种结构, 全局也只有一个线程

多线程调度器 1.0

多线程调度器引入了 GOMAXPROCS 变量帮助我们灵活控制程序中的最大处理器数, 即活跃线程数

任务窃取调度器 1.1

在多线程调度器上进行改进

  • 在当前的 G-M 模型中引入了处理器 P, 增加中间层
  • 在处理器 P 的基础上实现基于工作窃取的调度器

抢占式调度器 · 1.2 ~ 至今

  • 基于协作的抢占式调度器 - 1.2 ~ 1.13
    • 通过编译器在函数调用时插入抢占检查指令, 在函数调用时检查当前 Goroutine 是否发起了抢占请求, 实现基于协作的抢占式调度;
    • Goroutine 可能会因为垃圾回收和循环长时间占用资源导致程序暂停;
  • 基于信号的抢占式调度器 - 1.14 ~ 至今
    • 实现基于信号的真抢占式调度
    • 垃圾回收在扫描栈时会触发抢占调度;
    • 抢占的时间点不够多, 还不能覆盖全部的边缘情况;

对 Go 语言并发模型的修改提升了调度器的性能, 但是 1.1 版本中的调度器仍然不支持抢占式调度, 程序只能依靠 Goroutine 主动让出 CPU 资源才能触发调度

基于协作的抢占式调度器

Go 语言的调度器在 1.2 版本中引入基于协作的抢占式调度解决下面的问题:

  • 某些 Goroutine 可以长时间占用线程, 造成其它 Goroutine 的饥饿
  • 垃圾回收需要暂停整个程序(Stop-the-world, STW), 最长可能需要几分钟的时间, 导致整个程序无法工作

1.2 版本的抢占式调度虽然能够缓解这个问题, 但是它实现的抢占式调度是基于协作的, 在之后很长的一段时间里 Go 语言的调度器都有一些无法被抢占的边缘情况, 例如:for 循环或者垃圾回收长时间占用线程, 这些问题中的一部分直到 1.14 才被基于信号的抢占式调度解决

基于信号的抢占式调度器

Go 语言在 1.14 版本中实现了非协作的抢占式调度, 在实现的过程中我们重构已有的逻辑并为 Goroutine 增加新的状态和字段来支持抢占。Go 团队通过下面的一系列提交实现了这一功能, 我们可以按时间顺序分析相关提交理解它的工作原理

调度器的 GMP 模型

gmp

Processor,虚拟处理器,M和G所需要的资源的上下文, 程序启动时创建, 最大 GOMAXPROCS 可以设置数量, 它包含了运行 goroutine 的资源, 如果M想运行 goroutine, 必须先获取 P, P 中还包含了可运行的 G 队列(最大256个),

M , golang 对系统线程的封装, 真正执行代码必须要有线程, 操作系统分配到应用程序的内核线程数, 默认最大量10000个, 如果有M阻塞, 会创建一个新的M, 如果M空闲, M会被回收或者睡眠

新创建的G优先放在P的等待队列中, 如果G满了, 放入到全局的等待队列中

G的状态

- 空闲中 _Gidle	刚刚被分配并且还没有被初始化
- 等待运行 _Grunnable 没有执行代码,没有栈的所有权,存储在运行队列中
- 运行中 _Grunning 可以执行代码,拥有栈的所有权,被赋予了内核线程 M 和处理器 P
- 系统调用中 _Gsyscall 正在执行系统调用,拥有栈的所有权,没有执行用户代码,被赋予了内核线程 M 但是不在运行队列上
- 等待中 _Gwaiting 由于运行时而被阻塞,没有执行用    _Gpreempted	由于抢占而被阻塞,没有执行用户代码并且不在运行队列上,等待唤醒
- 户代码并且不在运行队列上,但是可能存在于 Channel 的等待队列上
- 已终止 _Gdead	没有被使用,没有执行代码,可能有分配的栈
- _Gcopystack	栈正在被拷贝,没有执行代码,不在运行队列上

调度策略

  • 复用线程

    • work-stealing

      当前处理器本地的运行队列中不包含 G 时, 调用会触发工作窃取, 则去全局队列偷取一部分G,如果全局队列也是空的,则去其他的P中偷取一部分G

      处理器持有一个由可运行的 Goroutine 组成的环形的运行队列 runq, 还反向持有一个线程。调度器在调度时会从处理器的队列中选择队列头的 Goroutine 放到线程 M 上执行

      基于工作窃取的多线程调度器将每一个线程绑定到了独立的 CPU 上, 这些线程会被不同处理器管理, 不同的处理器通过工作窃取对任务进行再分配实现任务的平衡, 也能提升调度器和 Go 语言程序的整体性能, 今天所有的 Go 语言服务都受益于这一改动

    • hand off

      当M处理的G,G处于阻塞时,M释放绑定的P,把P转移给其他空闲的M执行

  • 利用并行

    GOMAXPROCS 设置P的数据,最多有GOMAXPROCS个线程分布在多个CPU执行

  • 抢占
    一个Goroutine 最多占用CPU 10ms,防止其他 Goroutine 饿死

  • 全局 G 队列
    当 M 执行 work-stealing 会获取全局队列的G

go func() 执行流程

  1. go func() 创建G,加入到 P 队列中
  2. G只能在M中执行,M必须持有一个P,M与P是1:1的关系,M从P中取出G来执行
  3. 一个M调度G执行是一个循环机制(调度->执行->销毁->返回->调度)
  4. 如果G发生syscall或者阻塞,M会阻塞,如果当前有一些G在执行,runtime会把这个M从P中摘除(detach),重新创建或者拿取一个空闲线程用来服务P

GMP
https://maocat.cc/2022/12/31/golang/gmp/
发布于
2022年12月31日
许可协议