Go|Golang 调度器设计思想、GMP 协程调度模型详解

文章目录

    • 1 线程和协程
    • 2 GMP 概念
    • 3 GM 调度模型
    • 4 GMP 调度模型
    • 5 调度器设计
      • 设计思想
      • G 调度流程
      • 调度器生命周期 G0 M0
    • 6 抢占式调度

go tool traceGODEBUG 可查看运行时调度信息。
图片和部分内容参考 此博客

1 线程和协程

CPU 只能看到内核级线程,而无法看到绑定在内核级线程上用户级线程的情况,这里的用户级线程就是协程,协程需要由用户态的协程调度器进行调度。

线程由CPU调度是抢占式的,协程由用户态调度是协作式的,一个协程让出CPU后,才执行下一个协程。

内核级线程和协程存在三种对应关系。

  • N:1

    N 个协程绑定一个线程。

    优点:协程在用户态线程即完成切换,不会陷入到内核态,这种切换非常的轻量快速

    缺点:某个程序用不了硬件的多核加速能力,一旦某协程阻塞,造成线程阻塞,本进程的其他协程都无法执行了。

  • 1:1

    协程的创建、删除和切换的代价都由CPU完成,有点略显昂贵了。

  • M:N

    N:1和1:1类型的结合,克服了以上2种模型的缺点,但实现起来最为复杂。

Go|Golang 调度器设计思想、GMP 协程调度模型详解_第1张图片

2 GMP 概念

  • G(Goroutine):代表Go 协程Goroutine,存储了 Goroutine 的执行栈信息、Goroutine 状态以及 Goroutine 的任务函数等。G的数量无限制,理论上只受内存的影响,创建一个 G 的初始栈大小为2-4K,配置一般的机器也能简简单单开启数十万个 Goroutine ,而且Go语言在 G 退出的时候还会把 G 清理之后放到 P 本地或者全局的闲置列表 gFree 中以便复用。
  • M(Machine): Go 对操作系统线程(OS thread)的封装,可以看作操作系统内核线程,想要在 CPU 上执行代码必须有线程,通过系统调用 clone 创建。M在绑定有效的 P 后,进入一个调度循环,而调度循环的机制大致是从 P 的本地运行队列以及全局队列中获取 G,切换到 G 的执行栈上并执行 G 的函数,调用 goexit 做清理工作并回到 M,如此反复。M 并不保留 G 状态,这是 G 可以跨 M 调度的基础。M的数量有限制,默认数量限制是 10000,但是内核很难支持这么多的线程数,所以这个限制可以忽略。可以通过 debug.SetMaxThreads() 方法进行设置,如果有M空闲,那么就会回收或者睡眠。
  • P(Processor):调度器,虚拟处理器,M执行G所需要的资源和上下文,只有将 P 和 M 绑定,才能让 P 的 runq 中的 G 真正运行起来。P 的数量决定了系统内最大可并行的 G 的数量,P的数量受本机的CPU核数影响,可通过环境变量$GOMAXPROCS或在runtime.GOMAXPROCS()来设置,默认为CPU核心数
  • Sched:调度器结构,它维护有存储M和G的全局队列,以及调度器的一些状态信息

M与P的数量没有绝对关系,一个M阻塞,P就会去创建或者切换另一个M,所以,即使P的默认数量是1,也有可能会创建很多个M出来。

3 GM 调度模型

1.1 版本之前,Go早期是GM模型,没有P组件。

M想要执行、放回G都必须访问全局G队列,并且M有多个,即多线程访问同一资源需要加锁进行保证互斥/同步,所以全局G队列是有互斥锁进行保护的。

Go|Golang 调度器设计思想、GMP 协程调度模型详解_第2张图片

存在的问题:

  1. 全局队列的锁竞争,当 M 从全局队列中添加或者获取 G 的时候,都需要获取队列锁,导致激烈的锁竞争
  2. M 转移 G 增加额外开销局部性差),当 M1 在执行 G1 的时候, M1 创建了 G2,为了继续执行 G1,需要把 G2 保存到全局队列中,无法保证G2是被M1处理。因为 M1 原本就保存了 G2 的信息,所以 G2 最好是在 M1 上执行,这样的话也不需要转移G到全局队列和线程上下文切换
  3. 线程使用效率不能最大化,没有work-stealinghand-off 机制
  4. 系统调用(CPU在M之间的切换)导致频繁的线程阻塞和取消阻塞操作增加了系统开销。

4 GMP 调度模型

Processor,它包含了运行goroutine的资源,如果线程想运行goroutine,必须先获取P,P中还包含了可运行的G队列

线程是运行goroutine的实体,调度器的功能是把可运行的goroutine分配到工作线程上

Goroutine调度器和OS调度器是通过M结合起来的,每个M都代表了1个内核线程,OS调度器负责把内核线程分配到CPU的核上执行。
Go|Golang 调度器设计思想、GMP 协程调度模型详解_第3张图片

  1. 全局队列(Global Queue):存放等待运行的G。

  2. P的本地队列:同全局队列类似,存放的也是等待运行的G,存的数量有限,不超过256个。新建G’时,G’优先加入到P的本地队列,如果队列满了,则会把本地队列中一半的G移动到全局队列。

  3. P列表:所有的P都在程序启动时创建,并保存在数组中,最多有GOMAXPROCS(可配置)个。 在确定了P的最大数量n后,运行时系统会根据这个数量创建n个P。

  4. M:线程想运行任务就得获取P,从P的本地队列获取G,P队列为空时,M也会尝试从全局队列拿一批G放到P的本地队列,或从其他P的本地队列偷一半放到自己P的本地队列。M运行G,G执行之后,M会从P获取下一个G,不断重复下去。

    没有足够的M来关联P并运行其中的可运行的G。比如所有的M此时都阻塞住了,而P中还有很多就绪任务,就会去寻找空闲的M,而没有空闲的,就会去创建新的M

  • 核心数据结构代码
//src/runtime/runtime2.go
type g struct {
    goid    int64 // 唯一的goroutine的ID
    sched gobuf // goroutine切换时,用于保存g的上下文
    stack stack // 栈
    gopc        // pc of go statement that created this goroutine
    startpc    uintptr // pc of goroutine function
    ...
}

type p struct {
    lock mutex
    id          int32
    status      uint32 // one of pidle/prunning/...

    // Queue of runnable goroutines. Accessed without lock.
    runqhead uint32 // 本地队列队头
    runqtail uint32 // 本地队列队尾
    runq     [256]guintptr // 本地队列,大小256的数组,数组往往会被都读入到缓存中,对缓存友好,效率较高
    runnext guintptr // 下一个优先执行的goroutine(一定是最后生产出来的),为了实现局部性原理,runnext中的G永远会被最先调度执行
    ... 
}

type m struct {
    g0            *g     
    // 每个M都有一个自己的G0,不指向任何可执行的函数,在调度或系统调用时,M会切换到G0,使用G0的栈空间来调度
    curg          *g    
    // 当前正在执行的G
    ... 
}

type schedt struct {
    ...
    runq     gQueue // 全局队列,链表(长度无限制)
    runqsize int32  // 全局队列长度
    ...
}

5 调度器设计

早期不支持抢占式调度,这导致一旦某个 G 中出现死循环的代码逻辑,那么 G 将永久占用分配给它的 P 和 M,而位于同一个 P 中的其他 G 将得不到调度,出现“饿死”的情况。

在 Go 1.2 版本中实现了基于协作的“抢占式”调度,在Go 1.14 版本中实现了基于信号的“抢占式”调度

设计思想

  • 线程复用(work stealing 机制hand off 机制):避免频繁的创建、销毁线程,而是对线程的复用。
  • 利用并行(利用多核CPU):设置P的数量,最多有GOMAXPROCS个线程分布在多个CPU上同时运行。
  • 抢占调度(解决公平性问题):一个goroutine最多占用CPU 10ms,防止其他goroutine被饿死

G 调度流程

Go|Golang 调度器设计思想、GMP 协程调度模型详解_第4张图片

  1. 创建 Ggo func()
  2. 保存 G:新创建的G会先保存在P的本地队列中,如果P的本地队列已经满了就会保存在全局的队列中。
  3. 唤醒或新建 M,绑定 P,用于执行G:G只能运行在M中,一个M必须持有一个P。在创建G时,运行的G会尝试唤醒其他空闲的P和M组合去执行。
  4. M 获取 G:1⃣️ M首先从P的本地队列获取 G,2⃣️ 如果 P为空,则从全局队列获取 G,3⃣️ 如果全局队列也为空,则从另一个本地队列偷取一半数量的 G(负载均衡)。这种从其它P偷的方式称之为 work stealing
  5. M调度G执行
    • 在执行 G 的过程发生系统调用阻塞(同步),会阻塞G和M(操作系统限制),此时P会和当前M解绑,并寻找新的M,如果没有空闲的M就会新建一个M ,接着继续执行 P中其余的G,这种阻塞后释放P的方式称之为hand off
    • 系统调用结束后,这个G会尝试获取一个空闲的P执行,优先获取之前绑定的P,并放入到这个P的本地队列,如果获取不到P,那么这个线程M变成休眠状态,加入到空闲线程中,然后这个G会被放入到全局队列中。
    • 如果M在执行G的过程发生网络IO等操作阻塞时(异步),阻塞G,不会阻塞M。M会寻找P中其它可执行的G继续执行,G会被网络轮询器network poller 接手,当阻塞的G恢复后,G1从network poller 被移回到P的 LRQ 中,重新进入可执行状态。异步情况下,通过调度,Go scheduler 成功地将 I/O 的任务转变成了 CPU 任务,或者说将内核级别的线程切换转变成了用户级别的 goroutine 切换,大大提高了效率。
  6. M执行完G后清理现场,重新进入调度循环(将M上运⾏的goroutine切换为G0,G0负责调度时协程的切换

调度器生命周期 G0 M0

M0是启动程序后的编号为0的主线程,这个M对应的实例会在全局变量runtime.m0中,不需要在heap上分配,M0负责执行初始化操作和启动第一个G, 在之后M0就和其他的M一样了。

G0是每次启动一个M都会第一个创建的gourtine,G0仅用于负责调度的G,G0不指向任何可执行的函数, 每个M都会有一个自己的G0。在调度或系统调用时会使用G0的栈空间, 全局变量的G0是M0的G0。G0 是用来做调度的,例如:从 G1 切换到 G2 时,会先切回到 G0,保存 G1 的栈等调度信息,然后再切换到 G2。

package main
import "fmt"
func main() {
    fmt.Println("Hello world")
}

以上代码运行流程:

  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退出程序。
    Go|Golang 调度器设计思想、GMP 协程调度模型详解_第5张图片

调度器的生命周期几乎占满了一个Go程序的一生,runtime.main的goroutine执行之前都是为调度器做准备工作,runtime.main的goroutine运行,才是调度器的真正开始,直到runtime.main结束而结束。

6 抢占式调度

  • Go 1.2 中实现了基于协作的“抢占式”调度

    协作式:是否让出p的决定权在groutine自身。

    • 编译器会在调用函数前插入 runtime.morestack,让运行时有机会在这段代码中检查是否需要执行抢占调度
    • Go语言运行时会在垃圾回收暂停程序、系统监控发现 Goroutine 运行超过 10ms,那么会在这个协程设置一个抢占标记
    • 发生函数调用时,可能会执行编译器插入的 runtime.morestack,它调用的 runtime.newstack会检查抢占标记,如果有抢占标记就会触发抢占让出cpu,切到调度主协程里

    这种解决方案只能说局部解决了“饿死”问题,只在有函数调用的地方才能插入“抢占”代码(埋点),对于没有函数调用而是纯算法循环计算的 G,Go 调度器依然无法抢占

  • Go 1.14 中实现了基于信号的“抢占式”调度

    不管协程有没有意愿主动让出 cpu 运行权,只要某个协程执行时间过长,就会发送信号强行夺取 cpu 运行权。

    • M 注册一个 SIGURG 信号的处理函数:sighandler
    • sysmon启动后会间隔性的进行监控,最长间隔10ms,最短间隔20us。如果发现某协程独占P超过10ms,会给M发送抢占信号
    • M 收到信号后,内核执行 sighandler 函数把当前协程的状态从_Grunning正在执行改成 _Grunnable可执行,把抢占的协程放到全局队列里,M继续寻找其他 goroutine 来运行
    • 被抢占的 G 再次调度过来执行时,会继续原来的执行流

    抢占分为_Prunning_Psyscall_Psyscall抢占通常是由于阻塞性系统调用引起的,比如磁盘io、cgo。_Prunning抢占通常是由于一些类似死循环的计算逻辑引起的。

你可能感兴趣的:(Golang,golang,java,高并发,线程,GMP)