首页 > 编程笔记 > Go语言笔记 阅读:2

Go语言GMP调度模型详解(新手必看)

GMP 调度模型是 Go 语言实现并发的基础,在介绍 GMP 调度模型之前,我们先思考下面几个问题:
这些问题你可能了解一些,也可能不了解,不了解也不用担心,学习完本文之后,相信你会对 GMP 有一个比较清晰的认识。

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

其实协程是由线程 M 调度执行的。所以理论上只需要维护一个协程队列,再有个线程 M 能调度这些协程就可以了。

那逻辑处理器 P 是做什么的呢?貌似没有也行。其实 Go 语言最初版本确实就是这么设计的,这时候应该称之为 GM 调度模型,如下图所示:


图 1 GM调度模型

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

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

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


图 2 GMP调度模型

如图 2 所示,每一个逻辑处理器 P 都有一个本地可运行协程队列,线程 M 绑定逻辑处理器 P 之后才能调度协程。Go 语言调度协程比操作系统调度线程简单得多,目前只有简单的时间片调度以及抢占式调度。

另外可以看到,实际上还有一个全局可运行协程队列,这是为了避免多个逻辑处理器 P 的负载分配不均衡,新创建的协程在某些条件下会加入全局可运行协程队列,线程 M 在调度协程时,也有可能从全局可运行协程队列获取协程,当然这时候就需要加锁了。

我们已经知道,逻辑处理器 P 在一定程度上避免了低效的加锁操作,而 Go 语言后续的很多设计都采取了这种思想,包括定时器、内存分配等,都是通过将共享数据关联到逻辑处理器 P 上来避免加锁。

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

这里还有一个问题需要解决,线程创建后,操作系统自动分配线程栈,而操作系统根本不知道协程,那么如何为其分配协程栈呢?

实际上,协程栈是由 Go 语言自己管理的。看到这里你可能会觉得奇怪,Go 语言能自己管理协程栈吗?写过 C 程序的人都知道,开发者只能申请与管理堆内存,并不能管理线程栈,那么 Go 语言是如何管理协程栈的呢?这就不得不说一下 Linux 虚拟内存结构了,如下图所示。


图 3 Linux 虚拟内存结构

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

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

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

深入理解GMP调度模型

经过前面的学习,我们已经对 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      // 逻辑处理器P的状态,如空闲、正在运行(已经被线程M绑定)等
    m      muintptr    // 当前绑定的线程M
    runq   [256]guintptr // 本地可运行协程队列
}
结构体 m、g、p 的定义非常复杂,上面的代码只是列出了一些与 GMP 调度模型相关的字段。

结构体 m 的各字段解释如下:
结构体 g 的各字段解释如下:
结构体 p 的各字段解释如下:
看到了吧,GMP 调度模型其实并没有多复杂,扒开 Go 底层源码来看,GMP 只不过是三个比较复杂的数据结构罢了。针对逻辑处理器 P 再强调一点,我们一直说线程 M 必须绑定逻辑处理器 P 才能调度协程 G,而且逻辑处理器 P 只能被一个线程 M 绑定。

针对这一个设计理念,Go 语言还定义了逻辑处理器 P 的状态,定义如下:
const (
    _Pidle = iota // 空闲,没有绑定线程M
    _Prunning     // 正在运行,已经绑定了线程M
    _Psyscall     // 正在执行系统调用
    _Pgcstop      // 暂停中,垃圾回收可能需要暂停所有用户代码,需要暂停所有的P
    _Pdead        // 已死亡
)

逻辑处理器 P 的各状态含义如下:
结合 Go 语言对 GMP 模型的定义,以及我们对协程栈的理解,我们可以得到下图:


图 4 GMP 结构示意图

参考图 4,每一个线程 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 函数的某一条指令。

相关文章