前言

上一篇文章我们提到:多线程并发的时候会抢夺全局协程队列中的全局锁,造成高并发情况下效率低的问题。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:指向其服务的线程的原始指针。
  • runqheadrunqtailrunq:可执行的协程的队列,支持无锁访问。
  • runnext:下一个可用的协程的指针

image.png

这里的核心思想是:PMG之间的中介,P持有一些G,每次获取G不用从全局找。大大减少了并发冲突的情况。

GMP模型示意图

了解了G、M、P这三个结构体分别的作用,我们可以抽象出GMP模型:

image.png

即每个线程从各自的本地队列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结构体,重新入队等待被调度。大致示意图如下:

image.png

这种思路相当于在本地队列的调度里面增加了一种小循环,示意图如下:

image.png

在此基础上,我们需要思考清楚两个问题:第一是何时触发保存现场的函数,让线程重新调度。第二是目前这种方法只是配合本地队列的内部循环,可能还会造成全局协程的饥饿,应该如何解决?

全局协程饥饿问题

对于这个问题,我们的解决办法是:每隔一段时间以一定概率从全局队列里面取一批协程,参与到本地队列的循环,这样就不会造成全局队列的协程饥饿。

我们查看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,(stackPreempt0xfffffade),如果为真就执行gopreempt_m(),里面会调用schedule

我们可以用一个示意图来表示基于协作的抢占式调度:

image.png

基于信号的抢占式调度

我们注意到,有一些奇葩方法是没有函数调用的,这种方法也就不会被插入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工作时向目标线程发送信号,线程通过执行信号处理函数来触发重新调度。