Go语言修炼(八):面试必问的GMP调度模型
前言
上一篇文章我们提到:多线程并发的时候会抢夺全局协程队列中的全局锁,造成高并发情况下效率低的问题。G-M-P模型的出现就是为了解决这个问题。
GMP模型是Go语言运行时用于管理并发执行单元(Goroutine)的一套精妙设计。在这个模型中,Goroutine作为轻量级的线程,实现了高效的并发执行;Machine(M)代表操作系统级别的线程,是真正执行计算的实体;而Processor(P)则扮演着调度器与Goroutine之间的中介角色,负责将Goroutine分配到M上执行,以及管理Goroutine的上下文切换等任务。三者的协同工作,构成了Go语言强大的并发调度能力,使得编写高并发应用变得既简单又高效。
P结构体
上一篇文章说过,G
结构体是Go语言对协程这个数据结构的抽象,M
是对线程的抽象,那么GMP模型中的P
指的又是什么呢?
我们知道,多线程并发的时候会抢夺全局线程队列的锁,造成并发问题。既然从全局中获取需要抢夺,我们就不难想出一个解决方法:本地缓存的思想。
实际上,P
结构体就是利用了本地队列的思想。协程调度时,Go每次会从全局抓取N个协程,放到本地队列缓存起来,在调度协程时,访问本地队列取协程的时候是无须加锁的。
这个本地队列在Go语言中被抽象为P
结构体,在runtime/runtime2.go
中,部分代码如下:
type p struct {
id int32
status uint32 // one of pidle/prunning/...
link puintptr
schedtick uint32 // incremented on every scheduler call
syscalltick uint32 // incremented on every system call
sysmontick sysmontick // last tick observed by sysmon
m muintptr
// Queue of runnable goroutines. Accessed without lock.
runqhead uint32
runqtail uint32
runq [256]guintptr
runnext guintptr
P
结构体的重要字段有:
m
:指向其服务的线程的原始指针。runqhead
、runqtail
、runq
:可执行的协程的队列,支持无锁访问。runnext
:下一个可用的协程的指针
这里的核心思想是:P
是M
与G
之间的中介,P
持有一些G
,每次获取G
不用从全局找。大大减少了并发冲突的情况。
GMP模型示意图
了解了G、M、P这三个结构体分别的作用,我们可以抽象出GMP模型:
即每个线程从各自的本地队列P中获取协程并调度执行。这里还有一个问题,如果本地队列空了怎么办?
Go语言中,如果本地队列为空,会从全局队列获取一批协程放到本地队列中。另外,如果全局队列也空了,会从其它线程偷取一些协程过来执行。
底层源码学习
我们找到协程调度的schedule
方法的源码,其简化逻辑如下:
- 如果
gp == nil
,即拿不到协程,执行runqget(_g_.m.p.ptr())
,从当前运行的协程所属的线程的本地队列中获取runnext
,即下一个可运行协程。 - 如果拿不到,从全局中拿,执行
globrunqget()
,获取一批G
- 如果全局也没有
G
,会执行stealWork()
,从其它P
上拿一些协程过来执行(也叫做任务窃取,增加线程的利用率)
新创建的协程的执行流程
Go语言中,会优先执行新创建的协程。代码位置:runtime/proc.go/netproc()
,简化逻辑如下:
- 随机找一个P,将新协程放入P的
runnext
(即插队) - 如果P的本地队列满了,才会放到全局队列
协程重新调度:如何解决协程饥饿问题
目前我们已经解决了协程调度的第一个问题,即抢占全局所导致的效率低问题。然而,目前的协程是排队顺序执行,无法实现并发,并且有协程饥饿的问题。
所谓饥饿问题,指的是长任务协程会饿死短任务的协程的现象,这是因为协程调度并没有考虑到任务的优先级问题。
针对这个问题,我们不难想到的解决办法是:让协程执行业务方法时,如果超过一定的时间,就保存上下文信息并存储到g
结构体,重新入队等待被调度。大致示意图如下:
这种思路相当于在本地队列的调度里面增加了一种小循环,示意图如下:
在此基础上,我们需要思考清楚两个问题:第一是何时触发保存现场的函数,让线程重新调度。第二是目前这种方法只是配合本地队列的内部循环,可能还会造成全局协程的饥饿,应该如何解决?
全局协程饥饿问题
对于这个问题,我们的解决办法是:每隔一段时间以一定概率从全局队列里面取一批协程,参与到本地队列的循环,这样就不会造成全局队列的协程饥饿。
我们查看schedule
方法的源码,里面有这样一段逻辑:
if _g_.m.p.ptr().schedtick%61 == 0
,执行globrunqget
。也就是说如果线程循环执行了61次,就从全局队列拿出1个协程放到P中。这就解决了全局协程饥饿的问题。
触发重新调度的时机
我们应该如何做到业务方法执行到一半时,重新回到调度方法呢?一般来说有两种解决思路,一种是主动挂起,另一种是系统调用完成时。
主动挂起
主动挂起依赖runtime.gopark()
方法。
主动挂起即业务方法执行过程中,主动调用gopark()
方法,就可以做到从线程循环跳到schedule
去重新取G结构体。
我们可以查看一下gopark
方法中的相关逻辑:
- 执行后,线程会进入waiting state
- 然后调用
mcall(park_m)
,使用mcall
会从协程栈切换到g0
栈。 park_m
中调用了schedule
方法
需要注意的是,虽然gopark方法是不对外暴露的,但当我们使用time.Sleep
或者channel
等待的时候,会调用gopark
方法让协程休眠,一定时间后再唤醒。
系统调用完成时
除了依赖协程的主动挂起,我们注意到,我们Go程序不可避免会调用系统调用。
实际上,当我们系统调用完成后会使用exitsyscall()
方法,里面也会用mcall
调用一些方法,最后调用到schedule
。
到这里,上面的协程调度方法看似已经很完美了,其实还存在一个问题:如果某个协程永远不主动挂起,永远不系统调用该怎么办?这就要依赖抢占式调度了。
抢占式调度
对于那些从不主动挂起,又不执行系统调用的方法,我们需要使用到抢占式调度。既然是抢占式调度,那么抢占的时机又在哪里呢?
其实这里一般也有两种思路,第一种是基于协作的抢占式调度,另一种是基于信号的抢占式调度。
基于协作的抢占式调度
这种方法的思路就是:如果有一处代码所有协程经常会调用他,我们就可以尝试在这段代码中触发协程调度。而确实是有这种方法的,这就是runtime.morestack()
。
runtime.morestack()
的本意是检查协程栈是否有足够的空间。如果不足够会扩充栈。这个跟协程调度本来没有任何关系。在Go语言中,当我们调用一个方法时,这个方法就会被编译器插入morestack
这个栈帧。
利用这个特性,我们就可以在runtime.morestack()
里面加一个hook
。Go语言中,当系统监控到协程运行超过10ms
后,会将g.stackguard0
设置为0xfffffade
,即标记为抢占。然后,在执行morestack
时会判断某个协程是否被抢占,如果被抢占就直接回到schedule
。
这段逻辑在stack.go
中:
if gp.stackguard0 == stackPreempt
,(stackPreempt
即0xfffffade
),如果为真就执行gopreempt_m()
,里面会调用schedule
。
我们可以用一个示意图来表示基于协作的抢占式调度:
基于信号的抢占式调度
我们注意到,有一些奇葩方法是没有函数调用的,这种方法也就不会被插入morestack
栈帧,那重新调度也就不会被执行,例如下面的方法:
func go1(){
i:=0
for true {
i++
}
}
func main(){
go go1()
}
于是,有大神发明出了基于信号的抢占式调度。学过《操作系统》这门课的同学都知道,Linux为线程提供了很多信号量,比如SIGPIPE/SIGURG/SIGHUP
等,线程可以注册对应信号的处理函数。
基于信号的抢占式调度思路如下:
- 注册
SIGURG
信号的处理函数doSigPreempt
- 当
GC
工作时,向目标线程发送信号。 - 线程收到信号后触发调度。
至于为什么选择在gc
时候发信号,emmm,因为gc的时候刚好业务都停止,很适合重新调度。
小结
通过对GMP调度模型的深入探讨,我们不难发现,Go语言之所以能在并发编程领域大放异彩,离不开其背后这一精妙设计的支撑。GMP模型不仅解决了传统线程模型中的诸多问题,如线程创建与销毁的开销大、线程切换成本高、资源竞争与锁管理复杂等,还通过其灵活高效的调度策略,使得Goroutine的创建与管理变得轻如鸿毛,极大地提升了应用的并发处理能力和性能。
下面是对本文知识点的总结:
- GMP模型中P是协程的本地队列,解决了协程调度时抢占全局锁造成的执行效率低的问题。
- 以往的协程调度模型中,协程是顺序执行的,这会造成协程饥饿问题。
- 为了解决协程的饥饿问题,Go通过“主动挂起”和“系统调用完成时触发”两个时机来触发重新调度。
- 有些协程不会触发上述两种行为,可以通过抢占式调度解决。抢占式调度分为基于协作的抢占式调度和基于信号的抢占式调度。
- 基于协作的抢占式调度利用方法调用时触发的
morestack
,对已经标记为抢占的协程执行重新调度。 - 基于信号的抢占式调度,利用GC工作时向目标线程发送信号,线程通过执行信号处理函数来触发重新调度。
- 感谢你赐予我前进的力量