GMP调度模型

本文最后更新于:2022年9月20日 下午

开坑,今天太晚明天写


2021-07-12 09:14

这就是假期后遗症和懒。。。。。根本腾不出手来写东西

GMP调度模型

背景

这里还是不介绍并发、并行的区别了,我们先从多线程到多协程的转化开始吧。

现在绝大部分的开发语言都支持线程级别的并发,最典型的就是 Java 了,多线程尽管一定程度上提高了并发能力,但是在现如今高并发的场景,为每一个任务创建一个线程是不现实的,因为会消耗大量的资源(在32位操作系统中进程虚拟空间会占用4GB,线程会占用大约4MB的空间)。而且大量的线程会出现的问题:高内存占用、调度的高CPU消耗,高内存占用我们可以使用线程池技术来进行缓解,但是高CPU调度问题我们就得另寻他法了。

我们重新认识一下线程,一个线程实际分为了用户态线程和内核态线程,一个用户态线程必须绑定一个内核态线程,但是CPU不清楚用户态线程的存在,它只知道运行的是一个内核态的线程。这里我们可以把内核态的线程依然叫做线程(thread),而用户态的线程我们可以称其为协程(co-routine)

看到这里,我们就很容易理解到,既然一个协程必须绑定一个线程,那么是不是意味着多个协程可以绑定同一个线程呢。答案是可以的。此时协程在用户态即可完成切换工作,不会陷入内核态切换,这种切换是非常快捷的。现在我们就得到了协程调度模型图

image-20210712094815441

Goroutine

Gotoutine 来自协程概念,它是用户态的线程,可以让一组可服用的函数运行在一组内核态的线程中。一个 Goroutine的初始空间大概只有4KB左右,并且在这4KB内存就足够一个函数完成运行,当然 Goroutine 的内存也是可以扩容的,最大可以扩容到1GB。

GM模型

GM模型早在很久之前就被废弃了,没有撑到正式版本,接下来我们来了解一下GM模型是什么,

本文使用G来表示 Goroutine,使用M来表示线程

首先Go底层维护一个全局的协程队列,所有的G都会被放在这个全局队列中,同时多个M从全局队列中获取和放回G,也正因为如此,我们可以知道有多个线程访问同一个临界资源,这时候就需要对这个临界资源加锁,不然就会出现脏读等一系列问题。因此我们可以得出如下图

image-20210712140525051

该调度模型有几个缺点

  1. 多个M同时访问全局队列,无论是调度、放回、销毁一个 Goroutine 都需要获取锁,这样会形成激烈的锁竞争问题
  2. M移交G会照成延迟和额外的系统负载。比如当 G 中包含创建新协程的时候,M 创建了 G’,为了继续执行 G,需要把 G’交给 M’执行,也造成了很差的局部性,因为 G’和 G 是相关的,最好放在 M 上执行,而不是其他 M’
  3. 系统调用 (CPU 在 M 之间的切换) 导致频繁的线程阻塞和取消阻塞操作增加了系统开销

GMP模型

面对之前的GM调度模型的一些问题,Go设计了新的调度器。在新调度器中,除了M和G,我们新引入了P。P中包含了运行Goroutine 的资源,如果线程M想要执行G,就必须要先获取P,P中还包含了可运行的G队列。

框架

在GMP模型中,G是协程实体,线程M是运行G的实体,而调度器P的功能是把可以运行的G分配到工作线程中。接下来我们来看下GMP模型的整体运行图

假装有图片

我们来一一介绍一下图中的内容

  1. G全局队列:存放着等待运行的所有G
  2. P的本地队列:同G全局队列,存放的是P的待运行队列,可以存放的最大容量为256个。在G需要新建协程G’时,会优先加入本地队列中,如果发现本地队列已满则会放回一半到全局队列中
  3. P:所有的P会在程序启动时创建,最多存在 GOAXPROCS
  4. M:线程想要运行任务就需要获取P,从P的本地列表中取出G,如果发现本地列表为空,则优先偷取其余P的本地列表的一半,如果无法偷取则从全局列表中获取G,M 运行 G,G 执行之后,M 会从 P 获取下一个 G,不断重复下去

PM创建问题

P何时创建: 在程序启动的时候创建

M何时创建:当没有足够的M来关联P的时候,比如当前所有的M都阻塞了

调度策略

调度器的设计主要从 线程复用并行利用 两个方面来优化调度

  1. 线程复用:避免对M的频繁创建、销毁。
    1. work stealing 机制:当本线程无可以运行的G时,尝试从其余P对立中偷取G,而不是销毁线程
    2. hand off 机制:当线程M因为G进行系统调用阻塞时,线程会主动释放当前的P,把P交由其余M执行,如果没有多余的M的话会创建新线程M。
  2. 并行利用
    1. 多调度器:最多有 GOMAXPROCS 个P。
    2. 抢占:在Go中,一个Goroutine最多占用CPU10ms的时间,防止其余Goroutine被饿死,
    3. 全局G队列:在新的GMP模型中依然保留了全局队列,但是作用被大大削弱

调度过程

这里介绍一下GMP的调度过程,主要是我们在运行go func(){}的是否发生了什么。

  1. go func(){}执行
  2. 尝试直接加入P的局部队列,如果发现局部队列已满则加入全局G队列
  3. P尝试从局部队列中获取一个G执行,如果发现局部队列为空,则依次尝试从全局G队列中获取、从NetworkPool中获取、从其余P中偷取一部分G
  4. M获取P
  5. M执行P中的函数,如果运行结束就销毁G
  6. 如果在执行期间发生了系统调用阻塞,则尝试用休眠M队列中获取一个M,如果未能获取到则会新增一个M
  7. 新建的M会接管当前阻塞的P。
  8. 当系统调用阻塞结束之后,这个G会尝试获取一个空闲P执行,并放入到这个P的本地队列中。如果获取不到P,那么M就会变成休眠状态,加入到空闲线程队列中,G会被放入到全局队列中

调度器的生命周期

  1. 创建第一个线程M0
  2. 创建第一个Goroutine G0
  3. 关联M0和G0
  4. 调度初始化,包括创建P等
  5. 创建main()函数中的 Goroutine
  6. M绑定P
  7. M是否可以通过P获取到一个G,如果不能获取的话则休眠当前M,等待唤醒到第六步
  8. M设置环境变量,包括栈、程序计数器等
  9. M执行G
  10. G退出,此时M重新跳到第7步

这里说一下特殊的M0和G0。

  1. M0:M0是启动程序后的编号为0的主线程,这个 M 对应的实例会在全局变量 runtime.m0 中,不需要在 heap 上分配,M0 负责执行初始化操作和启动第一个 G, 在之后 M0 就和其他的 M 一样了。
  2. G0:G0是每启动一个M都是第一个创建的 GroutineG0仅用于负责调度的G,它不指向任何可以执行的函数,每一个M都拥有自身的G0,在调度或者系统调用时会使用G0的栈空间。全局变量的G0是M0的G0

我们来跟踪一段代码

1
2
3
4
5
6
7
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 调度器很轻量也很简单,足以撑起 goroutine 的调度工作,并且让 Go 具有了原生(强大)并发的能力。Go 调度本质是把大量的 goroutine 分配到少量线程上去执行,并利用多核并行,实现更强大的并发。

本文大量参考了Aceld的文章并进行个人总结,下面是作者的原文信息,写的非常精彩。


原文作者:Aceld
转自链接:https://learnku.com/articles/41728
版权声明:著作权归作者所有。商业转载请联系作者获得授权,非商业转载请保留以上作者信息和原文链接。

写在最后

2021-07-12 16:31

没想到上周五开篇的现在才写完,懒死我自己。。。


本博客所有文章除特别声明外,均采用 CC BY-SA 4.0 协议 ,转载请注明出处!