聊一聊GMP模型 | Go主题月

前言

面试造火箭,工作拧螺丝,相信大家对这个都深有体会,虽然平时干的活都是CRUD,但是面试的时候必须要问你原理,比如说 —— 你了解过GMP模型吗。

GMP模型的由来

并发场景下的多线程

相信大家在使用Go编程的时候,印象最深刻的应该就是Go的并发功能,而之所以Go能这么良好的支持并发,就是因为有了调度器模型GMP的关系。

在讲GMP之前,这里首先需要了解一下进程,线程还有协程之间的一些差别,具体可以点击这里

我们知道现在市面上的操作系统基本都是多线程/多进程的,但是这样的结构也会有一个问题,那就是存在线程间切换的开销,例如Linux系统在对待线程和进程上的态度是一样的,进程切换需要多长时间,线程也是这样。

f7998fb146c4e6d52579b195336846f6.jpeg

这样的情况,在线程数在线程数增多的情况下,就会带来很多的额外的开销,从而不能完美的利用好CPU,就好像我们利用了一个CPU的100%,其实可能的情况是,真是利用率只有60%,剩下的40%为线程切换所带来的额外的开销。

同时对于当前高并发场景下,如果为每一个任务创建一个线程来执行也是不太靠谱的,因为在32位系统下,一个进程的占用是4G,一个线程的占用内存也达到了差不多4M,如果线程数目过多,会直接把内存给占满了。

改良后的协程

面对多线程场景下的一些缺陷,为了解决这个问题,就有了协程这个概念。

在一个线程里面,线程是可以区分为内核线程跟用户线程(也就是协程),而在操作系统的内部CPU只关心的是内核线程,每一个内核线程至少绑定了一个用户线程(协程)。

fc562a2c953d9fc71ffae5523ecb4501.jpeg

那么既然这样,有没有可能存在其它类型的绑定情况呢,答案当然是有的

1:1的绑定

这种绑定方式是最简单,也是最原始的情况下的绑定情况。每一个协程,通过一个调度器绑定到一个线程里面去

2d4a7cbe1a25c4e39ec8ad0119e78cbf.jpeg

这种方式最大的优点就是实现简单,协程的调度交由CPU来完成。

但是同样的缺陷也是非常明显,在协程交由CPU调度的情况下,会带来额外的开销,也就是之前提到的。

N:1的绑定

这是一种相对改良之后的绑定方式,当N个协程通过调度器绑定在一个内核线程上面的时候,协程间的切换就交由调度器来完成了,也就减少了CPU调度所带来的开销,但是这种情况同样存在一个问题:

  1. 如果绑定在该线程上的某个协程阻塞了,那么这个线程也就会阻塞掉

  2. 因为只是想当与运用了单线程,不能很好的把CPU的资源都利用起来

4af27d67a1f7c31528d93c43c4b1142a.jpeg

N:M的绑定

这种也就是最良好的一种绑定方式,完美的客服上以上两种方式的一个弊端,这种实现方式,就是在面对并发的场景下,只需要把主要精力放在调度器上的优化即可,二者也就是接下来要讲的GMP模型的设计。

789778c82d2b3a4cb0f5243c53408298.jpeg

GMP模型的设计

在Go中,使用的就是协程的N:M的方式,但是Go在这里面做了非常多的优化。

Go对于协程的优化

首先一点优化,就是对于协程的一个优化,在Go中协程不再叫做co-routine,而是goroutine,同时优化了它的切换的量级,使得goroutine在相互切换的时候,可以更加的轻量。

在创建一个goroutine的时候,它的大小可能只有到几K的一个大小,所以在占用内存上,也会小了很多。

GMP的意义

这里首先介绍下GMP分别代表的意义

G —— goroutine

M —— 内核线程

P —— 逻辑处理器

其中在P,也就是逻辑处理器里面,包括了goroutine运行所需要的所有的资源,也就是说,每一个G,只有在绑定在P上面的时候,才可以被执行,而每一个P要去执行G,也必须绑定到M上才行。

GMP模型简介

b6212527d48273d9a712617b51825442.jpeg

图中,我们需要关心的部分,就是操作系统调度器以上的部分,其中默认情况下,服务器有几个CPU,初始化的时候就会有几个内核线程,当然,这也可以通过代码的方式来设置,默认情况下最大的线程数也就是10000条。

另外一个就是P的,而P的数量是同样也是由环境变量GOMAXPROCS来设置的。

其中M跟P的数量上,是没有一个绝对的关系的,P是在程序启动的时候,就会根据配置好的情况进行创建,最大也不会超过GOMAXPROCS的设置量,但是M会在执行一个goroutine并且发生阻塞的时候,如果没有可以使用的休眠线程,则会创建一个新的线程。

而每一个P上面都会有一个P的本地队列,这个本地队列就是用来存放goroutine的,每一个本地队列都会有一个存储的限制,最大的存储的goroutine为256个,如果在某一个P的本地队列里面存储的goroutine的个数超过了最大值,那么就会把这个队列里的一部分goroutine放到全局队列里面去(这点具体后面会讲到)。

而全局队列是一个带锁的队列,每一次P想要从全局队列里面获取一个goroutine,都必须抢占全局队列中的锁,来完成goroutine的偷取。

调度器的设计

调度器在设计的过程中,主要遵循着四个策略

  1. 复用线程
  2. 利用并行
  3. 抢占
  4. goroutine的全局队列

接下来我会逐一说明每一个策略的具体内容

复用线程

复用线程一共采用了两个机制:

  1. work stealing机制

这个机制的主要作用就是,当绑定了内核线程的P的本地队列为空的时候,他会去其他P的本地队列中偷取goroutine来进行执行

WX20210429-155500.png

  1. hand off机制

这个机制下,如果在执行G1的过程中,发生了一个阻塞的操作,为了保证后续的P的本地队列可以正常的执行,调度器首先会去尝试唤醒一个沉睡的线程,如果在线程队列里面也没有休眠的线程可以使用的时候,它会重新创建一个新的线程

当新的线程启动起来的时候,发生阻塞的M1上面绑定的P就会转移到新的线程上去,而原来的阻塞中的G则会跟原来的线程进行绑定,等待它阻塞完成。

新的线程则会接手这个P进行执行。

注意:当P转移走的时候,原来的M1是处于一个睡眠的状态,如果当G1阻塞完成的时候,还需要继续执行,则它会尝试去获取一个闲置的P,然后把G1放进这个P的队列中去,如果没有一个闲置的P,则M1会进入线程的休眠队列,而G1则会进入到全局队列去,等待其他P来把它取出来去执行。

WX20210429-161643.png

利用并行

我们前面有提到,可以通过GOMAXPROCS来设置P的数量,当P的数量大于1的时候,它多个P会绑定到多条线程上面去,这时候,所有的P的队列是并行执行的,我们看一下下面的一个代码

func main()  {
	var w sync.WaitGroup // 用来等待程序的完成
        
        // 没有设置GOMAXPROCS,则为默认值
        
	w.Add(2) //数字为2,表示有两个goroutine在运行
	go func() {
		defer w.Done()//函数运行结束的时候,通知main函数,想当与Add(-1)
		for i:=0;i<3 ;i++  {
			for char:='A';char<'A'+26 ;char++  {
				fmt.Printf("%c",char)
				//打印大写字母A~Z
			}
		}
	}()
	go func() {
		defer w.Done()
		for i:=0;i<3 ;i++  {
			for char:='a';char<'a'+26 ;char++  {
				fmt.Printf("%c",char)
				//打印小写字母a~z
			}
		}
	}()
	w.Wait()//等待goroutine的结束,在add不为0的时候阻塞
}
复制代码

它的执行结果是这样的

aABCDEFGHIJKLMNOPQRSTUVWXYZABCDEFGHIJKLbcdefghijklMNOPQRSTUVWXYZABCDEFGmnopqrstuvwxyzabcdefghijklHIJmnopqrstuvwxyzabcdeKLMNOPQfghijklmnopqrstuvwxyzRSTUVWXYZ
复制代码

我们可以看到它的字母的打印顺序是在交替打印的,也就是说打印大写字母和小写字母是同时进行的,只不过因为打印这个动作是一个io的操作,所以才会有一个交替的关系。

抢占型goroutine

前面我们提到了一个Go对协程的一个优化,而抢占也是同样对与goroutine的一个优化的效果

我们知道当一个co-routine在执行的时候,是占用着CPU的,如果这个co-routine不把这个CPU给释放出来,后续的co-routine是永远不会被执行到的。

但是在goroutine中却不一样,它没一个goroutine占用CPU的时间最长不能超过10ms,当超过10ms的时候,goroutine就会把这个CPU给进行释放,然后其它的goroutine则会去抢占这个CPU来进行执行,这样就确保了一个公平性,避免一个goroutine占用CPU的时间过长。

goroutine的全局队列

WX20210429-171930.png

从图中可以得知,当M1正在执行G1的时候,这时候,M2中的P队列是空的,若它想获取一个一个G来执行,它会优先从其它P队列里面去偷取,如果其它P队列也没有G,则这时候,它会去全局队列中,通过加锁和解锁的方式来取得全局队列中的G来执行。

go func调度的过程

72fab23c9addac1d73ebcbe6bb32613e.jpeg

从图中我们可以看到它的整个的执行过程

  1. 当程序里面执行go func的时候,它会创建出一个goroutine,然后这个goroutine会找到执行它的线程M1,然后尝试把自己放进M1对应的P的本地队列中,如果当前本地队列已经满了,它就会把自己放入到全局队列里面,等待其它P把它取出来执行。

  2. 当这个goroutine放到了本地队列上时,则P会调度这个goroutine来进行执行,如果P的本地队列里面没有goroutine,则这时候,它会优先去其它队列里面去偷取goroutine来执行,如果其他队列中也没有,那么它会去全局队列中去取

  3. 当这个goroutine开始执行的时候,也会有两种情况发生:

    3-1:goroutine顺利的执行,没有发生阻塞,则它会继续执行,如果规定时间没有执行完,它会重新放回本地队列中,等待下一次执行。

    3-2: goroutine发生了阻塞,这时候调度器会尝试找到休眠中的线程队列,在休眠队列里面,找到一个休眠中的线程把它唤醒,如果休眠队列中没有线程,则重新创建一个新的线程,这时候这个阻塞的goroutine会被绑定到执行的M1上去,而原来跟M1绑定的逻辑处理器P和P队列则会转移到新的线程M3上去,继续执行里面的队列。

    当这个阻塞的goroutine执行完毕的时候,M1会尝试获取一个新的空闲的P来继续执行,如果没有,则它会被放入休眠队列中,如果这时候线程数目已经超过了GOMAXPROCS的数,则该线程不会进入休眠,而是直接被销毁

注意点

  1. 所有的goroutine只能放到M里面去执行,而不是由P来执行,P只是负责调度。

  2. 所有的M要运行一个G,必须绑定一个P,它们的关系时1:1

  3. 在执行goroutine的过程中,它是一个循环的过程,知道这个P队列为空为止

Go执行生命周期

在Go生命周期里面有两个非常重要的概念,那就是M0和G0

M0的介绍

这里介绍M0拥有的一下几个特点

  1. 它是在启动一个进程之后,编号为0的一条主线程,也就是说,它的数量跟进程数目是一致的,同时也是全局唯一的一个

  2. 它是放在全局变量中的,不需要通过head来进行分配

  3. 它的主要作用是用来初始化和执行第一个goroutine,也就是main这个goroutine

  4. 当main启动完成之后,它就会变成同其它的线程一样的作用,需要抢占资源了

G0的介绍

  1. 每次启动一个M都会相对应的创建一个goroutine,也就是G0,这里G0是独立的,也就是每一个线程的创建,一定会创建出一个G0,也就是说每一个M,都会有一个自己的G0

  2. 同样的,它的存储为止也是在全局变量中

  3. G0没有一个跟踪的函数,就类似go 后面跟着的那个func一样

  4. 它的主要一个作用就是用来调度其它的goroutine,也就是在P在调度G到M上面执行的这一个过程,其实是由G0来完成的

执行一个main函数

那么在了解了M0和G0的情况下,我们来看下执行一个最简单的函数,具体它是一个怎么样的过程

微信截图_20210429234201.png

func main() {
    fmt.Print("hello world")
}
复制代码

这就是一句非常简单的打印一句hello world,那么我们来开一下它的执行步骤

  1. 首先当程序启动的时候,它会创建一个进程,然后这个进程会创建一个唯一的线程M0,同时上面由提到的M0会去创建出每个M都会有的G0,来跟它进行绑定

  2. 接着就会根据系统的配置,创建逻辑处理器,也就是P,P的本地队列和全局队列这些

  3. 我们知道main函数也是一个goroutine,当所有的一切都初始化完成之后,就是main这个G被创建出来

  4. 这时候G0为了调度main来执行,自己就会从M0上面解绑,接着M0就会去寻找一个空闲的P来与之进行绑定

  5. 当绑定完成之后,main就会被放入P的本地队列中,开始被执行

  6. 执行的是一个轮询的过程,知道main被执行完毕之后,就整个程序将会退出

这也就是执行一个最简单的Go函数的整体的一个过程了

最后

这篇文章我主要是参考了刘丹冰老师的B站视频,如果想看具体的视频,可以去B站搜索刘丹冰。

最后这是我在Go主题月里面的最后一篇文章,一个多月的学习让我也收获很多,虽然活动已经结束了,但是之后我还会继续深入的去学习和了解Go,坚持输出更多的文章!!我爱Go!!!

最后——大茶缸到手

© 版权声明
THE END
喜欢就支持一下吧
点赞0 分享