转自本同步公众号:“灰子学技术”
一、关于并发的基础知识
再讲goroutine的调度原理之前,有些与操作系统相关的知识,我们需要先知道,例如:
1.什么是并发?
并发:两个或两个以上的任务在一段时间内被执行。我们并不关心这些任务是否在同一时刻执行,我们只是知道,这些任务在这一段时间能能够都被执行,当然这一段时间可以很长,也可以很短。
2.并发的最小并发单位是什么?
进程是计算机资源分配最小的单位,是CPU分配资源的基本单位,具有独立的内存。
线程是计算机调度最小的单位,也是程序执行的最小单位,是在进程中的,一个进程往往会有一个到多个线程。
3.计算机是如何实现并发的?
计算机的分时调用是并发的根本,CPU通过快速的切换作业来执行不同的作业,基本的调度单位在执行的时候可以被阻塞掉,此时就会将CPU资源让出来,等到该调度单位再次被唤醒的时候,又可以使用CPU资源,而操作系统保证了整个的调度过程。
二、Goroutine的基础知识
除此之外,关于goroutine的调度原理,我们需要弄清楚下面几个问题。
1.goroutine是什么?
Goroutine:是Go里的一种轻量级线程——协程。
1)相对线程,协程的优势就在于它非常轻量级,进行上下文切换的代价非常的小。
2)对于一个goroutine ,每个结构体G中有一个sched的属性就是用来保存它上下文的。这样,goroutine 就可以很轻易的来回切换。
3)由于其上下文切换在用户态下发生,根本不必进入内核态,所以速度很快。而且只有当前goroutine 的 PC, SP等少量信息需要保存。
4)在Go语言中,每一个并发的执行单元为一个goroutine。
Go 语言中的goroutine并发, 采用的式CSP(communicating sequential processes)并发模型,讲究的是以通讯的方式来进行数据共享,是通过goroutine配合channel的方式来实现的。(备注:这部分知识后续单独整理一章。)
2.既然它是比线程还小的粒度,那么它与线程有什么关系?
Go语言的线程模型就是一种特殊的两级线程模型,如下所示:
两级线程模型的实现非常复杂,和内核级线程模型类似,一个进程中可以对应多个内核级线程,但是进程中的线程不和内核线程一一对应;这种线程模型会先创建多个内核级线程,然后用自身的用户级线程去对应创建的多个内核级线程,自身的用户级线程需要本身程序去调度,内核级的线程交给操作系统内核去调度。
三、Goroutine的调度策略
我们先来看下,Go线程实现了MPG模型:
S(Sched):结构就是调度器,它维护有存储M和G的队列以及调度器的一些状态信息等。
M(Machine):一个M直接关联了一个内核线程。
P(processor):代表了M所需的上下文环境,也是处理用户级代码逻辑的处理器。G(Goroutine):其实本质上也是一种轻量级的线程。
它们的关系如下所示:
介绍:
一个M会关联两个东西,一个是内核线程,一个是可执行的进程。
一个上下文P会有两类Goroutine,一类是正在运行的,图中的蓝色G;一类是正在排队的,图中灰色G,这个会存储在该进程中的runqueue里面。
这里的上下文P的数量也表示的是Goroutinue运行的数量,一般设置为几个,机器中就会并发运行几个。当然这里P的数量是可以设置的,通过环境变量GOMAXPROCS的值,或者通过运行时调用函数runtime.GOMAXPROCS()进行设置,最大值是256。
有了上面的知识,我们知道了Goroutine的一些基本概念,但是我们还是不知道,Go的并发是如何调度的。而这一个话题,就需要我们将Goroutine的几种场景(创建、销毁和运行)做拆分。
1.在执行go语句之前,我们看下程序都做了那些准备,也就是程序的初始化启动流程是什么样子的?
(来自:https://johng.cn/goroutine-scheduler-brief/)
上面的代码,有三个点非常关键,分别是runtime.schedinit,runtime.main,runtime.mstart
Step1: runtime.schedinit:这一步是调度器的初始化操作,它会设置GOMAXPROCS的大小,这里的大小不能超过它的上限256,并创建设置好对应数量的P,当然这些P都处于闲置状态;然后,将这些创建好的P都存放到sched中pidle所关联的闲置列表中。
Step2: 程序会继续执行runtime.newproc来创建程序的第一个goroutine,而这个goroutine会执行runtime.main也就是我们看到的main函数,在这之后main会主动创建一个内核线程M,这个M只用来做系统监控用,这个内核线程与程序中goroutinue的调度有关系。
Step3:在runtime.mstart之后,程序就开始执行了,如果后续需要创建goroutine,就会调用go语句来创建。
2.goroutine创建流程是什么样子的?
在调用go func()的时候,会调用runtime.newproc来创建一个goroutine,这个goroutine会新建一个自己的栈空间,同时在G的sched中维护栈地址与程序计数器这些信息(备注:这些数据在goroutine被调度的时候会被用到。准确的说该goroutine在放弃cpu之后,下一次在重新获取cpu的时候,这些信息会被重新加载到cpu的寄存器中。)
创建好的这个goroutine会被放到,它所对应的内核线程M所使用的上下文P中的runqueue中。等待调度器来决定何时取出该goroutine并执行,通常调度是按时间顺序被调度的,这个队列是一个先进先出的队列。
3.新建的这些goroutine是如何被调度的呢?
goroutine在创建好了之后,调度器会决定何时执行这个goroutine,这个过程就叫做调度。
新建好的goroutine,最开始都会存储在某一个线程M,所关联的上下文P的runqueue中,但是在后续的调度中,有些goroutine因为调用了runtime.gosched,会被放到全局队列中。
线程M的选择过程,按照下面的顺序执行:
1.从M对应的P中的runqueue中取出goroutine,来执行,没有的话,执行2。
2.从全局队列里面尝试取出一个goroutine来执行,有的话,执行!没有的话,执行3。
3.从其他的线程M的P中,偷出一些goroutine来执行,偷失败了,执行4。(备注:这里偷的话,一偷就偷一半,使用的算法叫做work stealing。)
4.线程M发现无事可做,就去休息了,也就是线程的sleep,它等待被唤醒。
4.运行中的goroutine是怎么停止的呢?一旦被停止了的话,那排队在它后面的goutinue该怎么办?
讲完了goroutine的调度之后,我们便要考虑一个问题,正在被执行的goroutine何时停止,停止了之后会发生什么?而挂在M对应的P后面的runqueue中的goroutine该怎么办?
情况1: runtime·park
当调用了runtime·park函数之后,goroutine会被设置成waiting状态,线程M会放弃它自身关联的上下文P,而系统会分配一个新的线程M1来接管这个上下文P,(备注:当然这里面的M1也有可能是本来就创建好的,处于闲置状态中的)。
原来的线程M0则会与上下文断开连接,M0因为无事可做,就去sleep了,等待下次被唤醒。如下图所示:
channel的读写操作,定时器中,网络poll等都有可能park goroutine。
情况2: runtime·gosched
调用runtime·gosched函数也可以让当前goroutine放弃cpu,这种情况下会将goroutine设置称runnable,放置到全局队列中。备注:这个也就是为什么全局变量的queue里面会有goroutine的原因。
5.goroutine被唤醒之后,会做什么?
goroutine处于waiting状态的话,在调用runtime·ready函数之后,会被唤醒,唤醒的goroutine会被重新放到,M对应的上下文所对应的runqueue中,等待被调度。
(图片来自:https://zhuanlan.zhihu.com/p/29698463)
参考资料:
学技术公众号:
有疑问加站长微信联系(非本文作者)