前言
面试造火箭,工作拧螺丝,相信大家对这个都深有体会,虽然平时干的活都是CRUD,但是面试的时候必须要问你原理,比如说 —— 你了解过GMP模型吗。
GMP模型的由来
并发场景下的多线程
相信大家在使用Go编程的时候,印象最深刻的应该就是Go的并发功能,而之所以Go能这么良好的支持并发,就是因为有了调度器模型GMP的关系。
在讲GMP之前,这里首先需要了解一下进程,线程还有协程之间的一些差别,具体可以点击这里
我们知道现在市面上的操作系统基本都是多线程/多进程的,但是这样的结构也会有一个问题,那就是存在线程间切换的开销,例如Linux系统在对待线程和进程上的态度是一样的,进程切换需要多长时间,线程也是这样。
这样的情况,在线程数在线程数增多的情况下,就会带来很多的额外的开销,从而不能完美的利用好CPU,就好像我们利用了一个CPU的100%,其实可能的情况是,真是利用率只有60%,剩下的40%为线程切换所带来的额外的开销。
同时对于当前高并发场景下,如果为每一个任务创建一个线程来执行也是不太靠谱的,因为在32位系统下,一个进程的占用是4G,一个线程的占用内存也达到了差不多4M,如果线程数目过多,会直接把内存给占满了。
改良后的协程
面对多线程场景下的一些缺陷,为了解决这个问题,就有了协程这个概念。
在一个线程里面,线程是可以区分为内核线程跟用户线程(也就是协程),而在操作系统的内部CPU只关心的是内核线程,每一个内核线程至少绑定了一个用户线程(协程)。
那么既然这样,有没有可能存在其它类型的绑定情况呢,答案当然是有的
1:1的绑定
这种绑定方式是最简单,也是最原始的情况下的绑定情况。每一个协程,通过一个调度器绑定到一个线程里面去
这种方式最大的优点就是实现简单,协程的调度交由CPU来完成。
但是同样的缺陷也是非常明显,在协程交由CPU调度的情况下,会带来额外的开销,也就是之前提到的。
N:1的绑定
这是一种相对改良之后的绑定方式,当N个协程通过调度器绑定在一个内核线程上面的时候,协程间的切换就交由调度器来完成了,也就减少了CPU调度所带来的开销,但是这种情况同样存在一个问题:
-
如果绑定在该线程上的某个协程阻塞了,那么这个线程也就会阻塞掉
-
因为只是想当与运用了单线程,不能很好的把CPU的资源都利用起来
N:M的绑定
这种也就是最良好的一种绑定方式,完美的客服上以上两种方式的一个弊端,这种实现方式,就是在面对并发的场景下,只需要把主要精力放在调度器上的优化即可,二者也就是接下来要讲的GMP模型的设计。
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模型简介
图中,我们需要关心的部分,就是操作系统调度器以上的部分,其中默认情况下,服务器有几个CPU,初始化的时候就会有几个内核线程,当然,这也可以通过代码的方式来设置,默认情况下最大的线程数也就是10000条。
另外一个就是P的,而P的数量是同样也是由环境变量GOMAXPROCS来设置的。
其中M跟P的数量上,是没有一个绝对的关系的,P是在程序启动的时候,就会根据配置好的情况进行创建,最大也不会超过GOMAXPROCS的设置量,但是M会在执行一个goroutine并且发生阻塞的时候,如果没有可以使用的休眠线程,则会创建一个新的线程。
而每一个P上面都会有一个P的本地队列,这个本地队列就是用来存放goroutine的,每一个本地队列都会有一个存储的限制,最大的存储的goroutine为256个,如果在某一个P的本地队列里面存储的goroutine的个数超过了最大值,那么就会把这个队列里的一部分goroutine放到全局队列里面去(这点具体后面会讲到)。
而全局队列是一个带锁的队列,每一次P想要从全局队列里面获取一个goroutine,都必须抢占全局队列中的锁,来完成goroutine的偷取。
调度器的设计
调度器在设计的过程中,主要遵循着四个策略
- 复用线程
- 利用并行
- 抢占
- goroutine的全局队列
接下来我会逐一说明每一个策略的具体内容
复用线程
复用线程一共采用了两个机制:
- work stealing机制
这个机制的主要作用就是,当绑定了内核线程的P的本地队列为空的时候,他会去其他P的本地队列中偷取goroutine来进行执行
- hand off机制
这个机制下,如果在执行G1的过程中,发生了一个阻塞的操作,为了保证后续的P的本地队列可以正常的执行,调度器首先会去尝试唤醒一个沉睡的线程,如果在线程队列里面也没有休眠的线程可以使用的时候,它会重新创建一个新的线程。
当新的线程启动起来的时候,发生阻塞的M1上面绑定的P就会转移到新的线程上去,而原来的阻塞中的G则会跟原来的线程进行绑定,等待它阻塞完成。
新的线程则会接手这个P进行执行。
注意:当P转移走的时候,原来的M1是处于一个睡眠的状态,如果当G1阻塞完成的时候,还需要继续执行,则它会尝试去获取一个闲置的P,然后把G1放进这个P的队列中去,如果没有一个闲置的P,则M1会进入线程的休眠队列,而G1则会进入到全局队列去,等待其他P来把它取出来去执行。
利用并行
我们前面有提到,可以通过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的全局队列
从图中可以得知,当M1正在执行G1的时候,这时候,M2中的P队列是空的,若它想获取一个一个G来执行,它会优先从其它P队列里面去偷取,如果其它P队列也没有G,则这时候,它会去全局队列中,通过加锁和解锁的方式来取得全局队列中的G来执行。
go func调度的过程
从图中我们可以看到它的整个的执行过程
-
当程序里面执行go func的时候,它会创建出一个goroutine,然后这个goroutine会找到执行它的线程M1,然后尝试把自己放进M1对应的P的本地队列中,如果当前本地队列已经满了,它就会把自己放入到全局队列里面,等待其它P把它取出来执行。
-
当这个goroutine放到了本地队列上时,则P会调度这个goroutine来进行执行,如果P的本地队列里面没有goroutine,则这时候,它会优先去其它队列里面去偷取goroutine来执行,如果其他队列中也没有,那么它会去全局队列中去取。
-
当这个goroutine开始执行的时候,也会有两种情况发生:
3-1:goroutine顺利的执行,没有发生阻塞,则它会继续执行,如果规定时间没有执行完,它会重新放回本地队列中,等待下一次执行。
3-2: goroutine发生了阻塞,这时候调度器会尝试找到休眠中的线程队列,在休眠队列里面,找到一个休眠中的线程把它唤醒,如果休眠队列中没有线程,则重新创建一个新的线程,这时候这个阻塞的goroutine会被绑定到执行的M1上去,而原来跟M1绑定的逻辑处理器P和P队列则会转移到新的线程M3上去,继续执行里面的队列。
当这个阻塞的goroutine执行完毕的时候,M1会尝试获取一个新的空闲的P来继续执行,如果没有,则它会被放入休眠队列中,如果这时候线程数目已经超过了GOMAXPROCS的数,则该线程不会进入休眠,而是直接被销毁。
注意点
-
所有的goroutine只能放到M里面去执行,而不是由P来执行,P只是负责调度。
-
所有的M要运行一个G,必须绑定一个P,它们的关系时1:1
-
在执行goroutine的过程中,它是一个循环的过程,知道这个P队列为空为止
Go执行生命周期
在Go生命周期里面有两个非常重要的概念,那就是M0和G0
M0的介绍
这里介绍M0拥有的一下几个特点
-
它是在启动一个进程之后,编号为0的一条主线程,也就是说,它的数量跟进程数目是一致的,同时也是全局唯一的一个
-
它是放在全局变量中的,不需要通过head来进行分配
-
它的主要作用是用来初始化和执行第一个goroutine,也就是main这个goroutine
-
当main启动完成之后,它就会变成同其它的线程一样的作用,需要抢占资源了
G0的介绍
-
每次启动一个M都会相对应的创建一个goroutine,也就是G0,这里G0是独立的,也就是每一个线程的创建,一定会创建出一个G0,也就是说每一个M,都会有一个自己的G0
-
同样的,它的存储为止也是在全局变量中
-
G0没有一个跟踪的函数,就类似go 后面跟着的那个func一样
-
它的主要一个作用就是用来调度其它的goroutine,也就是在P在调度G到M上面执行的这一个过程,其实是由G0来完成的
执行一个main函数
那么在了解了M0和G0的情况下,我们来看下执行一个最简单的函数,具体它是一个怎么样的过程
func main() {
fmt.Print("hello world")
}
复制代码
这就是一句非常简单的打印一句hello world,那么我们来开一下它的执行步骤
-
首先当程序启动的时候,它会创建一个进程,然后这个进程会创建一个唯一的线程M0,同时上面由提到的M0会去创建出每个M都会有的G0,来跟它进行绑定
-
接着就会根据系统的配置,创建逻辑处理器,也就是P,P的本地队列和全局队列这些
-
我们知道main函数也是一个goroutine,当所有的一切都初始化完成之后,就是main这个G被创建出来
-
这时候G0为了调度main来执行,自己就会从M0上面解绑,接着M0就会去寻找一个空闲的P来与之进行绑定
-
当绑定完成之后,main就会被放入P的本地队列中,开始被执行
-
执行的是一个轮询的过程,知道main被执行完毕之后,就整个程序将会退出
这也就是执行一个最简单的Go函数的整体的一个过程了
最后
这篇文章我主要是参考了刘丹冰老师的B站视频,如果想看具体的视频,可以去B站搜索刘丹冰。
最后这是我在Go主题月里面的最后一篇文章,一个多月的学习让我也收获很多,虽然活动已经结束了,但是之后我还会继续深入的去学习和了解Go,坚持输出更多的文章!!我爱Go!!!
最后——大茶缸到手