编程学习网 > 数据库 > Go语言之goroutine的调度原理
2020
01-06

Go语言之goroutine的调度原理

一、关于并发的基础知识

在讲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语句之前,我们看下程序都做了哪些准备,也就是程序的初始化启动流程是什么样子的?

上面的代码,有三个点非常关键,分别是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中,等待被调度。




扫码二维码 获取免费视频学习资料

Python编程学习

查 看2022高级编程视频教程免费获取