Go底层原理与工程化实践
上QQ阅读APP看本书,新人免费读10天
设备和账号都新为新人

2.1.2 GMP调度模型概述

通过前面的介绍,我们了解了并发的基本概念,对Go语言并发编程也有了一个基本的认识。GMP调度模型是Go语言实现并发的基础,因此本小节主要为大家介绍GMP调度模型的基本概念。

在介绍GMP调度模型之前,我们先思考下面几个问题。

1)协程为什么能并发执行呢?想想我们了解的线程,每一个线程都有一个栈帧,操作系统负责调度线程,而线程切换必然伴随着栈帧的切换。协程有栈帧吗?协程的调度由谁负责呢?

2)总是听到别人说协程是用户态线程,用户态是什么意思?协程与线程有什么关系?协程的创建以及切换不需要陷入内核态吗?

3)Go语言如何管理以及调度成千上万个协程?是否和操作系统一样,维护着可运行队列和阻塞队列?有没有所谓的按照时间片调度?或者是优先级调度?又或者是抢占式调度?

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

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

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

图2-1 GM调度模型

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

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

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

图2-2 GMP调度模型

如图2-2所示,每一个逻辑处理器P都有一个本地可运行协程队列,线程M绑定逻辑处理器P之后才能调度协程。Go语言调度协程比操作系统调度线程简单得多,目前只有简单的时间片调度以及抢占式调度。另外可以看到,实际上还有一个全局可运行协程队列,这是为了避免多个逻辑处理器P的负载分配不均衡,新创建的协程在某些条件下会加入全局可运行协程队列,线程M在调度协程时,也有可能从全局可运行协程队列获取协程,当然这时候就需要加锁了。

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

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

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

图2-3 Linux虚拟内存结构

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

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

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