深入理解Go语言中的GMP调度模型

tomato01 · · 650 次点击 · · 开始浏览    

   Go语言天然具备高并发特性,而高并发的基础就是GMP调度模型了,理解GMP调度模型对学习Go语言并发编程至关重要。GMP调度模型解释起来很简单,G(goroutine)代表协程,M(machine)代表线程,P(processor)代表逻辑处理器。    看到这,相信每个开发者都会比较疑惑,多多少少都会存在一些问题,比如: 1. 协程为什么能并发执行呢?想想我们了解的线程,每一个线程都有一个栈帧,操作系统负责调度线程,而线程切换必然伴随着栈帧的切换。协程有栈帧吗?协程的调度是谁负责呢? 1. 总是听到别人说协程是用户态线程,用户态是什么意思呢?协程与线程有什么关系呢?协程的创建以及切换不需要陷入内核态吗? 1. 为什么存在逻辑处理器的概念,它在调度模型里承担了什么职责呢? 1. Go语言是如何管理以及调度成千上万个协程呢?是否和操作系统一样,维护着可运行队列和阻塞队列?    这些问题你可能了解一些,也可能不了解,不了解也不用担心,学习完本篇文章之后,相信你会对GMP有一个比较清晰的认识。 ## GMP调度模型概述    首先明确一个概念,协程是Go语言的概念,操作系统是感知不到协程的,也就是说操作系统压根就不知道协程的存在,所以协程肯定不是由操作系统调度执行的。    其实协程是由线程M调度执行的。所以理论上只需要维护一个协程队列,再有个线程M能调度这些协程就可以了。那逻辑处理器P是做什么的呢?貌似没有也行。Go语言最初版本确实就是这么设计的,这时候应该称之为GM调度模型,示意图如图1所示。 ![image.png](https://static.golangjob.cn/240717/0283bcf9918a071355d2d70c265670eb.png)    但是需要注意的是,现代计算机通常是多核CPU,也就是说,通常会有多个线程M调度协程G。想想多个线程M从全局可运行协程队列获取协程的时候,是不是需要加锁呢?而加锁意味着低效。    所以,Go语言在后续版本引入了逻辑处理器P,每一个逻辑处理器P都有一个本地可运行协程队列,而线程M想要调度协程G,必须绑定一个逻辑处理器P,并且P只能被一个M绑定。这时候线程M只需要从其绑定的逻辑处理器P的本地可运行协程队列获取协程即可,显然这一操作是不需要加锁的。    那么,逻辑处理器P到底是什么呢?其实P只是一个有很多字段的数据结构而已,可以简单地将P理解成为一种资源,一般建议P的数目和计算机CPU核数保持一致。这时候的调度模型称为GMP调度模型,示意图如图2所示。 ![image.png](https://static.golangjob.cn/240717/9346c7a4af465d7ba6926cba2e42c98d.png) ## GMP调度模型之协程G    协程到底是什么呢?创建一个协程只是简单地创建一个数据结构吗?参考我们了解的线程,创建一个线程,操作系统会分配对应的线程栈,线程切换时,操作系统会保存线程上下文,同时恢复另一个线程上下文。协程需要协程栈吗?当然需要,因为协程和线程一样,都有可能被并发调度执行。    这里还有一个问题需要解决,线程创建后,操作系统自动分配线程栈,而操作系统根本不知道协程,那么如何为其分配协程栈呢?实际上,协程栈是由Go语言自己管理的。看到这里你可能会觉得奇怪,Go语言能自己管理协程栈?写过C程序的人都知道,开发者只能申请与管理堆内存,并不能管理线程栈,那么Go语言是如何管理协程栈的呢?这就不得不说一下Linux虚拟内存结构了,如图3所示。 ![image.png](https://static.golangjob.cn/240717/be7a5269194b7a2ebd2adfc0bf47f67f.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](https://static.golangjob.cn/240717/326b91ab93e4f4ea0e50a0df058669f9.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](https://static.golangjob.cn/240717/216753eb41e6f60b86703c44a5f00d04.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调度模型,理解了为什么协程被称为用户态、轻量级线程,理解了调度器的基本原理。 ![go底层原理与工程化实践.jpg](https://static.golangjob.cn/240717/61064fe0394a3d639c3bfcde2a0a3aa5.jpg)

有疑问加站长微信联系(非本文作者))

入群交流(和以上内容无关):加入Go大咖交流群,或添加微信:liuxiaoyan-s 备注:入群;或加QQ群:692541889

650 次点击  
加入收藏 微博
暂无回复
添加一条新回复 (您需要 登录 后才能回复 没有账号 ?)
  • 请尽量让自己的回复能够对别人有帮助
  • 支持 Markdown 格式, **粗体**、~~删除线~~、`单行代码`
  • 支持 @ 本站用户;支持表情(输入 : 提示),见 Emoji cheat sheet
  • 图片支持拖拽、截图粘贴等方式上传