1. 什么是 GMP?(必问)调度过程是什么样的?(对流程熟悉,要求更高,问的较少)#
什么是GMP?#
分析
gmp模型是go语言中的协程调度模型
G,M,P简单介绍#
G:Goroutine
M:内核线程,每个m都有1个特殊的协程g0,这个g0主要负责协程调度和切换,goroutine只有绑定到m上才能够正常运行
P:逻辑处理器Processor,包含goroutine本地队列,队列长度为256,当有 goroutine 要创建时,会被添加到 P 上的 goroutine 本地队列上,如果 P 的本地队列已满,则会维护到全局队列里
P和M的创建时机#
P何时创建:在确定了P的最大数量n后,运行时系统会根据这个数量创建n个P。
M何时创建:没有足够的M来关联P并运行其中的可运行的G。比如所有的M此时都阻塞住了,而P中还有很多就绪任务,就会去寻找空闲的M,而没有空闲的,就会去创建新的M。
回答
gmp是go语言协程调度模型,g代表goroutine,m代表内核线程,p代表逻辑处理器,p中包含本地g队列,g通过p绑定到m才能真正运行
调度过程是怎样的?#
分析
上面回答了gmp是go语言的协程调度模型,这个问题是对上一个问题的补充提问,进一步回答协程是怎样调度的。协程的调度是一个很复杂的过程,尽然是调度,肯定涉及到协程的上下文切换,调度策略以及调度时机还有调度过程,下面分为这几个场景来简单回顾一下,在回答这个提的时候不用这么详细,主要介绍协程的调度策略和调度时机即可。但是对于调度过程细问,比如问协程会给你上下文切换保存了哪些寄存器,发生调度的时机等问题要做到心中有数
协程上下文切换过程#
协程的调度主要是发生在用户的goroutine和g0之间,

协程经过g——>g0——>g的过程就完成了一次调度循环,一次协程调度过程跟线程的调度一样,也会发生协程的上下文切换,同样需要保存协程的执行现场,这样才能够切回g接着上次继续执行,协程的执行现场主要是几个寄存器的值,分别是rsp,rip,rbp。
- rsp:指向函数调用的栈顶
- rip:指向程序要执行的下一条指令地址
- rbp:存储函数栈帧的起始地址
这些寄存器主要保存在goroutine的sched这个字段结构中,goroutine的结构如下:
type g struct {
stackguard uintptr // 分段栈的可用空间下界
stackbase uintptr // 分段栈的栈基址
sched Gobuf // 协程切换时,利用sched域来保存上下文
stack0 uintptr
fnstart FuncVal* // goroutine运行的函数
param unsafe.Pointer // 用于传递参数
status int16 // 状态 Gidle,Grunnable,Grunning,Gsyscall,Gwaiting,Gdead
goid int64 // goroutine的id号
schedlink G*
m M* // for debuggers, but offset not hard-coded
lockedm M* // G被锁定只能在这个m上运行
gopc uintptr // 创建这个goroutine的go表达式的pc...
}
调度策略#
协程的调度过程可以认为是m寻找一个可以运行的g来运行的过程,优先从 P 的本地队列获取 goroutine 来执行;如果本地队列没有,从全局队列获取,如果全局队列也没有,从网络轮询器中查找是否有 Goroutine 等待运行;还是没有获取到,则会从其他的 P 上偷取 goroutine。
但是这种调度策略存在一个问题,如果本地p队列一直有g的话,那么全局队列的g可能完全没有机会执行?
所以,go的调度器在每执行61次调度,就会优先从全局队列获取一个g放到当前p队列。
如果本地运行队列已经满了,那么无法从 全局运行队列调用并放入怎么办?
如果本地运行队列满了,那么调度器会将本地运行队列的一半放入全局队列。这保证了当程序中有很多协程时,每个协程都有执行的机会
调度模式#
调度模式一般有两种,抢占式和协作式,协作式调度依靠被调度方主动弃权;抢占式调度则依靠调度器强制将被调度方被动中断
发生调度的时机#
- 等待读取或写入未缓冲的通道
- 由于 time.Sleep() 而等待
- 等待互斥量释放
- 发生系统调用
回答
协程在刚创建的时候,会优先加到当前p的本地队列中,等待被调度,当这个p队列满了的时候,本地队列满了时,会将本地队列的一半 G 和新创建的 G 一起放入全局队列。每个m都有一个特殊的协程g0负责调度工作,每一轮调度过程是这样的,M 优先执行其所绑定的 P 的本地运行队列中的 G,如果本地队列没有 G,则会从全局队列获取,为了提高效率和负载均衡,会从全局队列获取多个 G,而不是只取一个,同样,当全局队列没有时,会从其他 M 的 P 上偷取 G 来运行,偷取的个数通常是其他 P 运行队列的一半;如果还没有获取到g,则m就处于自旋状态。
2. GMP能不能去掉P层?会怎么样?#
分析
主要考察对p的作用的理解,因为在期初的时候,是单纯的gm模型,是没有p的,为什么会被弃用呢?假设没有p的话,也就没有本地p的g队列,则所有的m都将去同一个全局队列获取可用g,这样势必会有锁竞争问题,所以回答可以抓住这个点,从性能加以分析
回答
- 每个 P 有自己的本地队列,大幅度的减轻了对全局队列的直接依赖,所带来的效果就是锁竞争的减少。而 GM 模型的性能开销大头就是锁竞争。
- 每个 P 相对的平衡上,在 GMP 模型中也实现了 Work Stealing 算法,如果 P 的本地队列为空,则会从全局队列或其他 P 的本地队列中窃取可运行的 G 来运行,减少空转,提高了资源利用率。
3. M 和 P 的数量问题?#
分析
其实是上一个问题的补充问题,考察对gmp模型的了解深不深入,
P的数量:
由启动时环境变量 $GOMAXPROCS 或者是由 runtime 的方法 GOMAXPROCS() 决定
M的数量:
go语言本身的限制:go程序启动时,会设置M的最大数量,默认10000.但是内核很难支持这么多的线程数
runtime/debug 中的 SetMaxThreads 函数,设置M的最大数量
一个M阻塞了,会创建新的M。
G的数量:
理论上没有限制,受限于内存,但是goroutine过多会影响程序性能
4. 进程、线程、协程有什么区别?#
分析
进程,线程,还有协程都是并发单元,但是具体又有不同,在分析三者区别的时候可以从大小,调度,资源分配还有用户态或者是内核态等几个方面进行分析
回答
进程可以理解为一个动态的程序,进程是操作系统资源分配的基本单位,而线程是操作系统调度的基本单位,进程独占一个虚拟内存空间,而进程里的线程共享一个进程虚拟内存空间。线程的粒度更小,一个进程可以有多个线程
协程可以理解为用户态线程,跟线程的区别主要有三个方面
- 大小,协程大小为2k,可以动态扩容,而线程大小为2m,协程更轻量
- 线程切换需要用户态到内核态的切换,而协程的切换不用,只在用户态完成,线程切换需要保存各种寄存器,而协程切换只需要保存rsp, rip, rbp三个寄存器,协程切换消耗更小
- 线程的调度由操作系统完成,而协程的调度由运行时的调度器完成
5. 抢占式调度是如何抢占的?#
分析
本题其实是考察对go语言的协程调度方式的了解,一般的调度方式有两种,协作式和抢占式,协作式就是会主动让渡使用权,抢占式就是在一定情况下,使用权会被抢占。go语言的调度方式都是抢占式的,但是在Go1.14之前和Go1.14之后具体的抢占策略实现又有所不同,本题在回答的时候要注意区分go的版本,对Go1.14之前和之后的抢占策略熟悉,并且分析出Go1.14之后的抢占策略的优势
go语言调度方式#
go语言的调度模式在Go1.14 之前是基于协作的抢占式调度,在Go1.14及以后实现了基于信号的抢占式调度(异步抢占)
Go1.14 之前#
协作式调度就是m会主动让渡出p,让p可以与其他的m绑定,以下情况会发生这种主动让渡(协作调度):
而在下面情况下会发生抢占:
- 同一个goroutine运行超过10ms
抢占的实现原理:
Go 会启动一个线程,一直运行着"sysmon"函数,该函数实现了抢占式调度(以及其他诸如使网络处理的等待状态变为非阻塞状态)的功能。sysmon 运行在 M(Machine,实际上是一个系统线程),且不需要 P(Processor)
当 sysmon 发现 M 已运行同一个 G(Goroutine) 10ms 以上时,它会将该 G 的内部参数 preempt 设置为 true,当 G 进行函数调用时,G 会检查自己的 preempt 标志,如果它为 true,则它将自己与 M 分离并推入goroutine的局部队列,局部队列满了,再放入全局队列。抢占完成。
但是通过上述过程可以看到,要发生抢占,有1个前提,那就是发生函数调用,如果没有函数调用,即使设置了抢占标志,也不会进行该标志的检查,自然也就不会执行抢占过程。所以下述代码:
func main() {
go fmt.Println("hi")
for {
}
}
设置单核情况下,在go1.14之前这个代码将正常运行,被阻塞住,因为不会发生调度,for循环这个死循环不是函数调用,所以 preempt 标志检查这个阶段,不会发生抢占调度,这个goroutine不会被抢占,一直阻塞。
Go1.14 之后#
sysmon 会检测到运行了 10ms 以上的 G(goroutine)。然后,sysmon 向运行 G 的 P 发送信号(SIGURG)。Go 的信号处理程序会调用P上的一个叫作 gsignal 的 goroutine 来处理该信号,将其映射到 M 而不是 G,并使其检查该信号。gsignal 看到抢占信号,停止正在运行的 G。
由于此机制会显式发出信号,因此无需调用函数,就能将正在运行死循环的 goroutine 切换到另一个 goroutine
通过使用信号的异步抢占机制,上面的代码现在就可以按预期工作。 GODEBUG=asyncpreemptoff=1 可用于禁用异步抢占。
回答
Go1.14 之前是协作式抢占,Go 会启动一个线程,一直运行着"sysmon"函数,该函数实现了抢占式调度(以及其他诸如使网络处理的等待状态变为非阻塞状态)的功能。sysmon 运行在 M(Machine,实际上是一个系统线程),且不需要 P(Processor)
当 sysmon 发现 M 已运行同一个 G(Goroutine) 10ms 以上时,它会将该 G 的内部参数 preempt 设置为 true,当 G 进行函数调用时,G 会检查自己的 preempt 标志,如果它为 true,则它将自己与 M 分离并推入goroutine的全局队列,抢占完成
Go1.14 之后是异步式抢占,基于信号。 sysmon 会检测到运行了 10ms 以上的 G(goroutine)。然后,sysmon 向运行 G 的 M发送信号(SIGURG)。Go 的信号处理程序会调用M上的一个叫作 gsignal 的 goroutine 来处理该信号,并使其检查该信号。gsignal 看到抢占信号,停止正在运行的 G。
基于信号量的抢占可以防止类似于死循环这种没有发生函数调用的goroutine一直占用cpu导致程序阻塞,提高了程序的合理性