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 结构体,表示系统线程。它本身与一个内核线程进行绑定,是实际的执行体。
一开始的调度模型只有 G 和 M 两个结构。所有的 G 都放在一个全局队列 global runqueue 里,M 从这个队列里取 G 并执行 G 里保存的任务。
由于多个 M 都会从这个全局队列里获取 G 来运行,所以必须对该队列加锁保证互斥。这必然导致多个 M 激烈的锁的竞争,这也是老调度器最大的一个缺点。
具体来说,老调度器有4个缺点:详见 Scalable Go Scheduler Design Doc
创建、销毁、调度
G都需要每个M获取锁,这就形成了激烈的锁竞争M转移G会造成延迟和额外的系统负载。比如在G中创建新协程的时候,M创建了G2,为了继续执行G,需要把G2交给M2执行,也造成了很差的局部性,因为G2和G是相关的,最近访问的数据都在M的缓存里,最好放在M上执行,而不是其他M2。M中的mcache是用来存放小对象的,mcache、栈都和M关联造成了大量的内存开销和很差的局部性系统调用导致频繁的线程阻塞和取消阻塞操作增加了系统开销。
次代调度模型 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的栈内存,并在此空间内分配堆栈帧,执行任务函数中途切换时,将寄存器值保存回
G的sched字段即可记录状态,任何M都可以通过读这个G的该字段来恢复该G的状态。线程M本身只负责执行,不保存状态。这是并发任务跨线程调度,实现多路复用的根本所在。M还有个g0字段,在调度和执行系统调用时,使用这个g0,这样就不需要处理用户线程的栈相关的逻辑。这个g0有64KB的栈,也比普通协程2KB的栈大得多
M 并没有像 G 和 P 一样的状态标记,但可以认为一个 M 有以下的状态:
自旋中
spinning:M正在从运行队列获取G,这时候M会拥有一个P执行
go代码中:M正在执行go代码, 这时候M会拥有一个P执行原生代码中:
M正在执行阻塞的syscall或者cgo这时M并不拥有P(M可以无P执行原生代码,运行在g0上)休眠中:
M发现没有待运行的G时会进入休眠, 并添加到空闲M链表中, 这时M并不拥有P
自旋中 spinning 这个状态非常重要,是否需要唤醒或者创建新的 M 取决于当前自旋中的 M 的数量。
通常创建一个 M 的原因是由于没有足够的 M 来关联 P 并运行其队列里的 G。此外运行时 sysmon 、执行阻塞性系统调用、或者 GC 的时候也会创建 M。
P (Processor),处理器
P 是线程 M 和 G 的中间层,用于调度 G 在 M 上执行。
P自身是用一个全局数组allp来保存的,长度默认为GOMAXPROCS(一般设为 CPU 核数)它的主要用途就是用来执行
goroutine的,所以它维护了一个本地G队列,里面存储了所有需要它来执行的goroutine。本地队列避免了竞争锁(解决了初代模型最大的问题)。
P为线程提供了执行资源,如对象分配内存,本地G队列等。线程独享P资源,可以无锁操作为何要维护多个
P呢?是为了当一个 OS线程M陷入阻塞时,P能从之前的M上脱离,转而在另一个M上运行P里设计了两级G,一个runnext存储下一个要运行的G,和一个runq存储待运行的G列表
Schedt
Schedt结构就是一个调度器,它维护有存储M和G的队列以及调度器的一些状态信息等。它是一个共享的全局变量,全局只有一个
schedt类型的实例。里面存放了空闲(即休眠)
M列表、空闲P列表、全局G队列、废弃的G队列
重要的全局变量
实现
创建 G
使用
go func()关键字时,编译器会通过cmd/compile/internal/gc.state.stmt和cmd/compile/internal/gc.state.call两个方法将该关键字转换成runtime.newproc函数调用,创建新的goroutine也不一定是从新创建,会先尝试复用空闲的
G先从
P的gfree字段取空闲G,没有再从sched.gfree链表里转移一批空闲G到P里,再重试再没有,才会新建
新的
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下)。
通过 g0,M 可以无 P 执行 runtime 管理指令,比如 systemstack 这种执行方式。
在进程执行过程中,有两类代码需要运行。
一类是用户逻辑,直接使用当前
G的栈内存另一类是运行时管理指令,它并不便于直接在用户栈上执行,因为涉及到切换,需要保存用户栈、执行自己的逻辑、再恢复用户栈,要处理与用户逻辑现场有关的一大堆事务,很麻烦。
因此,当需要执行管理指令时,会将线程栈临时切换到 g0,与用户逻辑彻底隔离。
问题
P 的 runq大小为什么是 256?
太小会频繁触发和全局队列的交换。太大会频繁触发偷窃。
参考
Last updated