5-2.调度-MPG

简介

GMP 结构是 Go 中用于调度的三个结构体。

调度时,从 A任务切换到 B任务,需要保存 A任务的上下文,包括寄存器、栈指针、函数执行进度(程序计数器)等,G 就是用来保存上下文的载体

M 对应操作系统的线程,所有的 G 放到一个队列里,线程从这个队列里取任务运行。多个线程并发读取队列时,需要加全局锁,有比较严重的性能问题。

后来加上了P,把全局G队列打散成每个P里一个本地G队列,解决了的锁的问题。调度时:依次从P本地队列、全局队列、网络读写事件、从别的P偷窃G 调度运行。

调度

所谓并发,是指 CPU 一会儿处理几下 A 程序的代码,一会儿又处理几下 B 的,看起来就好像一秒内同时在处理 A 和 B 程序。

而公平的分配 A 和 B 的运行时间,就是 调度 所做的事情。

Go 调度器的发展历程

  • 单线程调度器 0.x

    • 程序中只能存在一个活跃线程M,由 G-M 模型组成

  • 多线程调度器 1.0

    • 有多个M,允许运行多线程程序

    • 全局锁导致竞争严重

  • 任务窃取调度器 1.1

    • 在当前的 G-M 模型中引入了中间层 P,将原来塞在一个全局队列里的G 打散到多个 P 中

    • 在 P 的基础上实现基于工作窃取的调度器

    • 在某些情况下,Goroutine 不会让出线程,导致饥饿问题

    • 时间过长的垃圾回收 (Stop the world, STW) 会导致程序长时间无法工作

  • 抢占式调度器 1.2 ~ 1.14

    • 抢占 = 当前协程休眠,让出 CPU 给别的协程

    • 基于协作的抢占式调度器 1.2 ~ 1.13

      • 通过编译器,在编译时向函数头部插入抢占检查代码,在函数调用时判断是否触发抢占,让出当前 CPU

      • 有一些无法被抢占的边缘情况:例如空的 for 循环、垃圾回收长时间占用线程等情景

      • 需要函数调用作为入口才能触发抢占,所以这是一种协作式的抢占式调度

    • 基于信号的抢占式调度器 1.14

      • 程序启动时,注册对 SIGURG 信号的处理函数

      • 垃圾回收在扫描栈时会触发抢占,向线程发送信号 SIGURG

      • 处理函数收到抢占信号后,获取当前协程,让其休眠并让出线程

      • 抢占的时间点不够多,还不能覆盖全部的边缘情况

初代调度模型 MG

G

调度就是 CPU 暂停当前的工作,转而执行另一个工作。

为了实现调度,需要引入一个数据结构保存 CPU寄存器的值以及 goroutine 的其它一些状态信息,这个载体就是 G

G 被调离 CPU 时,调度器代码负责把 CPU 寄存器的值保存在 G 对象的成员变量之中。当 G 被调度起来运行时,调度器代码又负责把 G 对象的成员变量所保存的寄存器的值恢复到 CPU 的寄存器。

M

G 只保存状态信息,不负责执行任务。真正执行任务的是 M 结构体,表示系统线程。它本身与一个内核线程进行绑定,是实际的执行体。

一开始的调度模型只有 GM 两个结构。所有的 G 都放在一个全局队列 global runqueue 里,M 从这个队列里取 G 并执行 G 里保存的任务。

由于多个 M 都会从这个全局队列里获取 G 来运行,所以必须对该队列加锁保证互斥。这必然导致多个 M 激烈的锁的竞争,这也是老调度器最大的一个缺点。

具体来说,老调度器有4个缺点:详见 Scalable Go Scheduler Design Doc

  1. 创建、销毁、调度 G 都需要每个 M 获取锁,这就形成了激烈的锁竞争

  2. M 转移 G 会造成延迟和额外的系统负载。比如在 G 中创建新协程的时候,M 创建了 G2,为了继续执行G,需要把 G2 交给 M2 执行,也造成了很差的局部性,因为 G2G 是相关的,最近访问的数据都在 M 的缓存里,最好放在 M 上执行,而不是其他 M2

  3. M 中的 mcache 是用来存放小对象的,mcache 、栈都和 M 关联造成了大量的内存开销和很差的局部性

  4. 系统调用导致频繁的线程阻塞和取消阻塞操作增加了系统开销。

次代调度模型 MPG

在 Dmitry Vyokov 实现的次代调度模型里,加上了 P 这一层,将全局唯一的 G 队列打散到了 P 里,每个 P 自己维护一个 G 的局部队列,解决原来的全局锁问题。

除了上面三个结构体以外,还有一个存放所有可运行 goroutine 的容器 schedt。每个 Go 程序中 schedt 结构体只有一个实例对象,在代码中是一个共享的全局变量,每个工作线程都可以访问它以及它所拥有的 goroutine 运行队列。

另外,尽管 P/M 构成执行组合体,但两者数量并非一一对应。 P 的数量默认为 CPU 数,而 M 是由调度器按需创建的。比如,当 M 因陷入系统调用阻塞时,P 就会被监控线程抢回,去新建/唤醒一个 M 执行其他任务,这样 M 的数量就会增长。

详情

G (goroutine)

  • G 维护了 goroutine 需要的栈、程序计数器以及它所在的 M 等信息。

  • G 并非执行体,仅保存并发任务状态,为任务提供所需要的栈空间

  • G 创建成功后放在 P 的本地队列或者全局队列,等待被调度

  • G 使用完毕后会被放回缓存池里,等待被复用

    • 因此存在:如果瞬发新建了大量 G,高峰后这些 G 会一直留着,不释放内存的问题。

    • 两级缓存池:每个 P 里有本地 G 缓存,schedt 里还有个全局 G 缓存,共两级

    • P 里的缓存池是 gFree 字段, G 工作结束后(状态变为 Gdead),会放入这里。读写它不需要锁

    • P.gFree 里的空闲 G 超过 64 个后,会分一半的 G 到全局缓存 schedt.gFree 里。schedt.gFree 又根据 G 的栈大小分成了两个队列

      • 为节省空间,G 的栈如果超过 2KB,那回收时不会保留它的栈,放入 schedt.gFree.noStack

      • 否则放入另一个队列 schedt.gFree.Stack

sudog

G 遇到阻塞场景时,比如向 channel 发送/接收阻塞时,会被封装为 sudog 这样一个结构。其实就是给 G 加了个 elem 字段,用于记录 channel 发送的数据。

M (Machine) 线程

M 表示操作系统线程。调度器最多可以创建 10000 个线程,但是其中大多数的线程都不会执行用户代码(可能陷入系统调用),最多只会有 GOMAXPROCS 个活跃线程能够正常运行。

在默认情况下,运行时会将 GOMAXPROCS 设置成当前机器的核数,我们也可以在程序中使用 runtime.GOMAXPROCS 来改变最大的活跃线程数。

  • 一个 M 就是一个用户级线程

  • M 是一个很大的结构,里面维护小对象内存 cache、当前执行的 goroutine、随机数发生器等等非常多的信息。

  • M 通过修改寄存器,将执行栈指向 G 的栈内存,并在此空间内分配堆栈帧,执行任务函数

  • 中途切换时,将寄存器值保存回 Gsched 字段即可记录状态,任何 M 都可以通过读这个 G 的该字段来恢复该 G 的状态。线程 M 本身只负责执行,不保存状态。这是并发任务跨线程调度,实现多路复用的根本所在。

  • M 还有个 g0 字段,在调度和执行系统调用时,使用这个 g0,这样就不需要处理用户线程的栈相关的逻辑。这个 g064KB 的栈,也比普通协程 2KB 的栈大得多

M 并没有像 GP 一样的状态标记,但可以认为一个 M 有以下的状态:

  1. 自旋中 spinningM 正在从运行队列获取G,这时候 M 会拥有一个 P

  2. 执行 go 代码中: M 正在执行 go 代码, 这时候 M 会拥有一个 P

  3. 执行原生代码中: M 正在执行阻塞的 syscall 或者 cgo这时 M 并不拥有 PM 可以无 P 执行原生代码,运行在 g0 上)

  4. 休眠中: M 发现没有待运行的 G 时会进入休眠, 并添加到空闲 M 链表中, 这时 M 并不拥有 P

自旋中 spinning 这个状态非常重要,是否需要唤醒或者创建新的 M 取决于当前自旋中的 M 的数量。

通常创建一个 M 的原因是由于没有足够的 M 来关联 P 并运行其队列里的 G。此外运行时 sysmon 、执行阻塞性系统调用、或者 GC 的时候也会创建 M

P (Processor),处理器

P 是线程 MG 的中间层,用于调度 GM 上执行。

  • P 自身是用一个全局数组 allp 来保存的,长度默认为 GOMAXPROCS(一般设为 CPU 核数)

  • 它的主要用途就是用来执行 goroutine 的,所以它维护了一个本地 G 队列,里面存储了所有需要它来执行的 goroutine

    • 本地队列避免了竞争锁(解决了初代模型最大的问题)。

  • P 为线程提供了执行资源,如对象分配内存,本地 G 队列等。线程独享 P 资源,可以无锁操作

  • 为何要维护多个 P 呢?是为了当一个 OS线程 M 陷入阻塞时,P 能从之前的 M 上脱离,转而在另一个 M 上运行

  • P 里设计了两级 G ,一个 runnext 存储下一个要运行的 G,和一个 runq 存储待运行的 G 列表

Schedt

  • Schedt 结构就是一个调度器,它维护有存储 MG 的队列以及调度器的一些状态信息等。

  • 它是一个共享的全局变量,全局只有一个 schedt 类型的实例。

  • 里面存放了空闲(即休眠) M 列表、空闲 P 列表、全局 G 队列、废弃的 G 队列

重要的全局变量

实现

创建 G

  • 使用 go func() 关键字时,编译器会通过 cmd/compile/internal/gc.state.stmtcmd/compile/internal/gc.state.call 两个方法将该关键字转换成 runtime.newproc 函数调用,创建新的 goroutine

    • 也不一定是从新创建,会先尝试复用空闲的 G

    • 先从 Pgfree 字段取空闲 G,没有再从 sched.gfree 链表里转移一批空闲 GP 里,再重试

    • 再没有,才会新建

  • 新的 goroutine 会被加到本地队列里

    • 新建的协程会被放到本地队列的头部(runnext 字段)

    • 调度时,会从队列里 pop 一个 goroutine,设置栈和 instruction pointer,开始执行这个 goroutine

  • P 的本地队列长度超过 256 时,里面一半的 G 会被转移至全局队列

  • 任务队列分为三级,分别是 P.runnext, P.runq, Sched.runq,可以视为三级协程池,很有些 CPU 多级缓存的意思。

newproc1 函数会先找空闲的 G,如果没有则创建新的 G 结构体,并拷贝 fn 函数的参数到 G 的栈上

gfget 函数会尝试从 P 的本地空闲队列里找 G,如果本地队列为空而全局空闲队列 sched.gFree 不为空,则从全局队列里取32个空闲 G 挪到 P 的本地队列里再取出

如果本地和全局空闲队列里都没有空闲 G,则调用 malg() 函数创建一个具有 2KB 栈空间的新 G

runqput 函数会尝试将新建的 G 写入 P 本地队列的 runnext 字段。本地队列如果满了,放入全局队列

可以看到只向本地队列写入时,逻辑是无锁的,提升效率。

创建P

P 只在初始化的时候创建,数量为 GOMAXPROCS,一般建完就不会再变了,除非用户调用 runtime.MAXGOPROCS 调整 P 的数量。

不过官方注释说 runtime.MAXGOPROCS 在后续改进调度器后会被移除。

创建 M

newproc 里可以看到一个 wakep() 函数,里面调用了 startm 函数找一个空闲 M 或新建 M 执行空闲 P。如果没有空闲 M,就会调用 newm 函数新建一个 M

newm() 在 linux 平台底层调用的是 clone() ,传入的函数是 mstart(),而 mstart() 里会递归调用 schedule(),不会停下来。因此 M 一旦被创建,就不再会被销毁了,最多是通过 stopm() 休眠,放入 sched 的空闲 M 列表里。

M 比较特别的地方是自带一个名为 g0,默认 8KB 栈内存的 G 属性对象。它的栈内存地址被传给 clone 函数,作为系统线程的默认堆栈空间(Linux下)。

通过 g0M 可以无 P 执行 runtime 管理指令,比如 systemstack 这种执行方式。

在进程执行过程中,有两类代码需要运行。

  • 一类是用户逻辑,直接使用当前 G 的栈内存

  • 另一类是运行时管理指令,它并不便于直接在用户栈上执行,因为涉及到切换,需要保存用户栈、执行自己的逻辑、再恢复用户栈,要处理与用户逻辑现场有关的一大堆事务,很麻烦。

因此,当需要执行管理指令时,会将线程栈临时切换到 g0,与用户逻辑彻底隔离。

问题

P 的 runq大小为什么是 256?

太小会频繁触发和全局队列的交换。太大会频繁触发偷窃。

参考

Golang调度与MPG

深入理解Go语言(03):scheduler调度器 - 基本介绍

golang 调度学习-综述

golang核心原理-协程调度时机

lucifer_L49715 - 理解golang调度之二 :Go调度器

Last updated