1

   Go语言天然具备高并发特性,而高并发的基础就是GMP调度模型了,理解GMP调度模型对学习Go语言并发编程至关重要。GMP调度模型解释起来很简单,G(goroutine)代表协程,M(machine)代表线程,P(processor)代表逻辑处理器。

   看到这,相信每个开发者都会比较疑惑,多多少少都会存在一些问题,比如:

  1. 协程为什么能并发执行呢?想想我们了解的线程,每一个线程都有一个栈帧,操作系统负责调度线程,而线程切换必然伴随着栈帧的切换。协程有栈帧吗?协程的调度是谁负责呢?
  2. 总是听到别人说协程是用户态线程,用户态是什么意思呢?协程与线程有什么关系呢?协程的创建以及切换不需要陷入内核态吗?
  3. 为什么存在逻辑处理器的概念,它在调度模型里承担了什么职责呢?
  4. Go语言是如何管理以及调度成千上万个协程呢?是否和操作系统一样,维护着可运行队列和阻塞队列?

   这些问题你可能了解一些,也可能不了解,不了解也不用担心,学习完本篇文章之后,相信你会对GMP有一个比较清晰的认识。

GMP调度模型概述

   首先明确一个概念,协程是Go语言的概念,操作系统是感知不到协程的,也就是说操作系统压根就不知道协程的存在,所以协程肯定不是由操作系统调度执行的。

   其实协程是由线程M调度执行的。所以理论上只需要维护一个协程队列,再有个线程M能调度这些协程就可以了。那逻辑处理器P是做什么的呢?貌似没有也行。Go语言最初版本确实就是这么设计的,这时候应该称之为GM调度模型,示意图如图1所示。

image.png

   但是需要注意的是,现代计算机通常是多核CPU,也就是说,通常会有多个线程M调度协程G。想想多个线程M从全局可运行协程队列获取协程的时候,是不是需要加锁呢?而加锁意味着低效。

   所以,Go语言在后续版本引入了逻辑处理器P,每一个逻辑处理器P都有一个本地可运行协程队列,而线程M想要调度协程G,必须绑定一个逻辑处理器P,并且P只能被一个M绑定。这时候线程M只需要从其绑定的逻辑处理器P的本地可运行协程队列获取协程即可,显然这一操作是不需要加锁的。

   那么,逻辑处理器P到底是什么呢?其实P只是一个有很多字段的数据结构而已,可以简单地将P理解成为一种资源,一般建议P的数目和计算机CPU核数保持一致。这时候的调度模型称为GMP调度模型,示意图如图2所示。

image.png

GMP调度模型之协程G

   协程到底是什么呢?创建一个协程只是简单地创建一个数据结构吗?参考我们了解的线程,创建一个线程,操作系统会分配对应的线程栈,线程切换时,操作系统会保存线程上下文,同时恢复另一个线程上下文。协程需要协程栈吗?当然需要,因为协程和线程一样,都有可能被并发调度执行。

   这里还有一个问题需要解决,线程创建后,操作系统自动分配线程栈,而操作系统根本不知道协程,那么如何为其分配协程栈呢?实际上,协程栈是由Go语言自己管理的。看到这里你可能会觉得奇怪,Go语言能自己管理协程栈?写过C程序的人都知道,开发者只能申请与管理堆内存,并不能管理线程栈,那么Go语言是如何管理协程栈的呢?这就不得不说一下Linux虚拟内存结构了,如图3所示。

image.png

   如图3所示,Linux虚拟内存被划分为代码段、数据段、运行时堆、共享区、线程栈和内核区域。线程栈是由操作系统维护的,开发者通过malloc申请的内存大多在运行时堆区域。既然操作系统不能维护协程栈,那么Go语言是否可以自己申请一块堆内存,将其用作协程栈呢?可是,这明明是运行时堆啊,协程运行过程中,操作系统怎么知道这块堆内存就是栈呢?

   其实栈内存是由两个寄存器标识的,寄存器RSP指向栈顶,寄存器RBP指向栈底,而用户程序可以修改寄存器的内容。也就是说,Go语言只需要申请一块堆内存,并且修改寄存器RBP以及RSP的内容,使其指向这块堆内存就行了。这样对操作系统而言,这块堆内存就是栈了。

   总结一下,操作系统并不知道协程的概念,并且协程可以像线程一样被调度执行,所以我们才说协程就是用户态的线程。协程栈就是将堆内存当成栈来用而已,每一个协程都对应一个协程栈,协程间的切换,对Go语言来说,也只不过是寄存器RBP和RSP的保存以及恢复,并不需要陷入内核态。

   最后,协程与线程类似,可以有多个状态,并且可以在不同状态之间转移,Go语言定义的协程状态如下所示:

_Gidle = iota     // 0,空闲状态,刚申请的g还未完成初始化
_Grunnable         // 1,可运行状态,已经添加到可运行队列等待调度
_Grunning         // 2,运行中状态
_Gsyscall         // 3,系统调用中,说明当前协程正在执行系统调用
_Gwaiting         // 4,阻塞状态,说明协程正在因为获取锁等原因阻塞
_Gdead         // 6,结束状态
_Gcopystack         // 8,栈扩容,协程栈内存不足时会自动扩容

   参考协程各状态的定义,可以画出协程的状态转移图,如图4所示:

image.png

GMP调度模型之调度器

  每一个线程M都有一个调度协程g0,g0协程的主函数是runtime.schedule,该函数实现了协程调度功能。怎么调度协程呢?第一步当然是获取到一个可运行协程G;第二步就是切换到协程G的上下文(包括切换协程栈,指令跳转)。

  思考一下,Go语言调度器都有哪些途径去获取可运行协程G呢?首先每一个逻辑处理器P都有一个可运行协程队列,调度器一般情况下只需要从当前逻辑处理器P的可运行协程队列获取协程即可。另外,Go语言为了避免多个逻辑处理器P负载分配不均衡,还有一个全局可运行协程队列,一定条件下也会从全局可运行协程队列获取协程。当然,如果逻辑处理器P的本地可运行协程队列为空,全局可运行协程队列也为空的话,调度器还会尝试其他方法获取协程,比如从其他逻辑处理器P的本地可运行协程队列去“偷”。

  我们可以简单看一下函数runtime.schedule的实现逻辑,代码如下所示:

func schedule() {
    // 检测是否有定时任务到达触发时间
    checkTimers(pp, 0)
    ……
    // 从逻辑处理器P的本地可运行协程队列获取协程
    if gp == nil {
        gp, inheritTime = runqget(_g_.m.p.ptr())
    }
    // 继续尝试其他手段获取协程
    if gp == nil {
        gp, inheritTime = findrunnable() // blocks until work is available
    }
    //调度执行(需要切换协程上下文)
    execute(gp, inheritTime)
}

  函数runtime.execute用于切换到协程G的上下文,以此实现协程的调度执行。这一逻辑底层是通过汇编代码实现的,这里就不作过多介绍了。

GMP调度模型之深入理解

  Go语言针对GMP分别定义了对应的数据结构,如下面代码所示:

type m struct {
    g0      *g                // g0就是调度"协程",执行调度程序
    curg    *g                // 当前正在调度执行的协程  
    p       puintptr          // 当前绑定的P

}

type g struct {
    goid      int64           // 协程id   
    stack     stack          // 协程栈   
    m         *m             // 当前协程被哪一个M调度执行
    sched     gobuf   // 协程上下文,用于保存协程栈寄存器RBP、RSP,以及指令寄存器PC
}

type p struct {
    status      uint32            // 状态,如空闲,正在运行(已经被M绑定)等等
    m           muintptr          // 当前绑定的m
    runq       [256]guintptr      // 本地可运行协程队列
}

  结构体m、g、p的定义非常复杂,上面代码只是列出了一些与GMP调度模型相关的字段。

  看到了吧,GMP调度模型其实并没有多复杂,扒开Go底层源码来看,GMP只不过是三个比较复杂的数据结构罢了。

  结合Go语言对GMP模型的定义,以及我们对协程栈的理解,我们可以得到图5。

image.png

  参考图5,每一个线程M都有一个调度协程g0,g0协程的主函数是runtime.schedule,该函数实现了协程调度功能。每一个协程都有一个协程栈,这个栈不是操作系统维护的,而是Go语言在运行时堆申请的一块内存。线程M必须绑定逻辑处理器P才能调度协程G,每一个逻辑处理器P都有一个本地可运行协程队列。协程在切换时,需要保存/恢复上下文信息,如栈寄存器RBP、RSP,指令寄存器PC,这些信息在协程G对应的结构体都有定义。

  最后需要注意的是,协程G的主函数gofunc,调度协程g0的主函数runtime.schedule,都存储在Linux虚拟内存的代码段,协程G的pc字段,指向的就是gofunc函数的某一条指令,协程g0的pc字段,指向的就是runtime.schedule函数的某一条指令。

总结

  GMP调度模型是Go语言高并发编程的基础,本文对GMP调度模型的基本概念作了详细介绍。通过学习本篇文章,相信你已经对很多问题有了较为清晰的认识,包括理解了GMP调度模型,理解了为什么协程被称为用户态、轻量级线程,理解了调度器的基本原理。


李烁
156 声望92 粉丝