前言
虽然上两篇文章讲了几个并发示例和Goroutine的使用,但是我仔细看了一下,还是不够清楚,这次这篇文章将会是一篇纯理论的文章,主要讲一下Go的并发机制的原理还有Goroutine还有channel。
至于为啥要写呢。。。。。
1 | 朋友们好啊,我是Go开发人员Yk,刚才有个朋友问我, |
还是要感谢这么几本书,《Go并发编程实战》,《Go语言圣经》,《Go高级编程》,感谢这几本书的作者和译者!感谢你们!
Go的线程模型
Go的并发机制是个什么,主要就是Go有一个特有的线程模型,这个线程模型里面,有个特有的线程,叫做Goroutine,这个东西可以理解为一个可执行可并发的代码块,通过channel管道去进行状态同步或者消息传递。
首先来看一下那个线程模型,术语叫做”两级线程模型”,名字其实没啥狗屁用,就听听就得了。
Go的线程模型,有三个核心元素
- M:machine,代表工作线程,你想成僵尸母体就行了
- P:processor,上下文环境,意思就是执行Go代码的所需要的资源,或者是存储要执行的goroutine,你想成僵尸传播渠道就行了
- G:goroutine,Go代码片段,你要执行并发的那一块儿程序。你想象成被感染的小僵尸就行了
他们的大致关系如下
这三个元素是互相依赖,互相依存的关系,我用僵尸传播理论来说明一下他们的关系吧。
首先,G需要P和M的支持,也就是说,一个G的出现,是先有了M(僵尸母体),然后通过渠道(P),感染其他的小僵尸(G)。一个M对应一个P,P对应多个G,G也对应多个P,因为感染人数不只是一个人,所以,他们的关系就像这样
如图所示
这么理解一波,M是内核线程,按照一般的逻辑,一个内核线程一般运行一个任务,但是,GOlang比较牛逼,通过调度器,使得M可以运行多个用户线程G,其中,P的作用是,当遇到内核线程阻塞的时候,M可以放弃P,这样,其他的G就可以被调度到其他M上,持续接力执行。
类似这样
所以,同样配置的机器,Golang的效率就会成倍增长,并且可以迅速切换goroutine。所以这就是Go为什么比其他语言快的原因,这也是Go最核心的东西,说真的,Go这个玩意儿,比Python那套好使多了,不过不同任务不可同日而语,继续一波。
Go的调度器
上面我讲到了调度器,什么是调度器?
顾名思义,就是调度用的,相当于交通警察,看哪儿阻塞了就给你安排到不赌的路上去,这么想是不是就整明白了,但是Go里面的调度器没那么简单,它的功能相对来说复杂一点。例如空闲的M列表,空闲的P列表,需要运行的G列表等等,都属于调度器的管理部分。
那么调度器是怎么把上面说的G,M,P串联起来的呢?这个翻了一大堆资料,总算弄明白了一点东西,在这里大概讲一下吧。
首先,调度器调度的主要对象就是M,P,G的实例,每个M在运行的时候都会执行调度任务,看过黑社会吗,调度器就是选老大时候的邓伯那帮人,选阿乐还是选大D都是他们协调说了算的。
调度器调度了个什么?寂寞吗,看看图,大概就是这样
调度器是有自己的数据结构的
1 | type schedt struct { |
下面说一下调度具体干了什么
调度的具体步骤
调度器结构体里面有几个重要参数,我先在这里整出来,后面你们方便看
调度器的具体流程这里整了一个图,大家凑合着看一下吧。图的来源是https://www.infoq.cn/article/r6wzs7bvq2er9kuelbqb,很感谢他!
看一下逻辑,首先将其分为四个阶段,绑定,创建,执行,释放。调度在其中的作用体现在了这里
首先第一个步骤绑定开始,这里也是M启动的过程,首先从空闲的P列表里面拿取一个P,然后绑定在M上,P里面有两个列表去管理G,一个runq是存放当前P中可运行G的一个队列,另外一个gfee是存放空闲G的一个队列,启动M之后,则会等待拿取可执行的G
第二个步骤,创建G,首先创建完之后,扔一个G到当前绑定P的runq队列当中
第三个步骤,执行G,M从绑定的P那里的runq队列中拿取一个G进行执行
第四个步骤,释放G,执行完G之后,将执行完毕的G放入gfee队列,当再次创建G的时候,从gfee列表中获取,这里是一个复用的逻辑,避免频繁创建G占用系统内存。
所以这里是类似一个自循环的逻辑,执行完G1之后持续执行,当M1繁忙时,自动开启新的M来执行
多线程下的调度机制(偷取G机制)
前阵子我在开发的时候,有一种场景是下载大文件,一般这种情况就是划块下载,但是下载时间是不可控的,也是未知的,很可能有的下的快,有的下的慢,就会出现有的下载队列已经空了,但是有的依旧还很满这种情况。
其实在Go里面,调度器在这一步就会寻找可执行的G,这里是它们的具体流程
如图
这里用流程图表示就是
我将这里的大概步骤编写如下
- 从本地P的可运行G队列(p.runq)中获取G. 调度器首先会尝试从此处获取G,并且返回一个结果
从调度器的可运行G(sched.runq)队列获取G。调度器首先会尝试从此处获取G,并且返回一个结果
从其他P中可运行的G队列中获取G。 在某些条件下,调度器会使用伪随机算法在全局P列表中选取一个P,并且尝试从他们的可运行G队列中盗取(转移)一半的G到本地的P可运行队列中,这里会重复多次盗取动作,成功之后就把盗取的一个G作为结果进行返回。
这里就完成了盗取机制。
那么偷取部分是怎么实现的呢?
偷取部分的源码如下
1 | // runtime/proc.go |
转移部分的代码如下
1 | // runtime/proc.go |
第三步,我说到”满足一个条件,才可以偷取”,这个条件其实定义的稍微复杂一些,分为两种
1 | 1. 除了本地的P外,其他有不为空的P |
当满足上述条件的时候,才可以开启偷取机制。
总之,调度器会权力查找可执行的G,它会调用多方资源来满足当前M,也就是我刚描述的,下载任务有快慢,会占用多余资源,但是调度器解决了闲置的问题,充分发挥了资源优势,这,相当牛逼了。
GC机制
在Golang里面,垃圾回收是基于CMS算法的,CMS算法我在这里简单描述一下吧
洋文叫:Concurrent Low Pause Collector,jvm也是这套算法,玩java的一眼就明白吧。
要说这套算法,Golang也用它,说明人家是经过考验的
Golang这套CMS算法分为三种执行模式
- gcbackgroundMode 并发垃圾收集/清理
- gcforceMode 串行垃圾收集,并发垃圾清理
- gcforceBlockMode 串行垃圾收集,串行垃圾清理
一般涉及到并发,调度器部分会自动GC,都是采用了gcbackgroundMode模式,首先会检查Go 程序的内存用量,检测增量过大的时候才会来一发GC。
一般在Golang当中,我们可以通过环境变量GODEBUG控制GC,一般修改
1 | // 转为gcforceMode 串行垃圾收集,并发垃圾清理 |
一般GC的触发,是在Go程序分配的内存翻倍增长时被触发的。如果想要手动GC,可以调用
1 | runtime.GC() |
进行一次手动GC
串行的GC触发方式为
1 | runtime.freeOsMemory() |
如果手动调用GC,将不会检测原有Go程序的内存使用量,是为强制GC。
隐藏的特殊成员-g0
在启动Go程序的时候,有一个特殊的隐藏G,叫做g0。
这里的G,不是Go程序代码里面的那个G,是一开始,初始化流程中自动分配给M的g,这个g0和上面那个GC对应起来了,它负责的就是监控内存,垃圾回收,执行调度。
一般由Go代码生成的G,称为用户G,而g0,则被称为系统G,一个系统一个用户,谁权限大是不是显而易见了。
每个M都会生成一个g0,Go运行的时候会进行切换,g0是不会被阻塞的,也不会被垃圾回收监控扫描。
所以g0,想象成一个守护神,伴随M,和M同生公死,相当于皇帝身边的大太监一样。
结尾
这篇文章写完,我是真的彻底搞懂了调度器原理和Golang并发核心机制,其实学习最好的方法就是写博客,记笔记,边写边记。其实慢慢来,都会有好的结果,已经到了年底了,今年大家都过的好嘛?或许今年过的很苦,但今天我们可乐。
end。