Golang协程调度器和GMP模型

先会过一个初级但是很重要的概念(在Java多线程中也不停地强调):

  • 并行:多个处理器同时处理多个任务,无论在宏观还是微观上,每一时候都是多个任务同时被执行;

  • 并发:由单个处理器同时处理多个任务,在宏观上仿佛多个任务在被同时执行,其实微观上,同一时刻只能有一个任务被执行,只是时间被划分为很多个小的时间片,处理器不停地交替执行多个任务;

并行和并发,都是为了充分利用和提高CPU的使用率!

一、Golang协程调度器的由来

1、单进程时代不需要调度器:

早期的操作系统每个程序就是一个进程,直到一个程序运行完,才能进行下一个进程,就是“单进程时代”;(性能极其低下)

  • 单路的执行流程,计算机只能一个一个地处理任务;

  • 万一某个任务进程阻塞了,将带来大量的CPU时间浪费;

1694400211648228.png

那么能不能由多个进程(在宏观角度)一起来执行多个任务呢?—— 并发

2、多进程/多线程时代有了调度器需求:

其实我在关于Linux操作系统底层知识扫盲的文章中,关于内核部分也重点讲过关于“进程调度器”的分类和策略等知识点:

Linux操作系统底层基础知识扫盲

  • 进程调度的分类:

    • 非抢占式:除非主动让出,否则一直运行;

    • 抢占式:由“进程调度器”强制安排;

  • 进程调度的策略:

    • 经典Unix(0) 调度策略:(看似公平,其实有可能浪费)

    • CFS完全公平调度策略:(根据优先级,调整时间片比例)

  • Linux中默认的调度策论策略(混合):

    • 对于“普通进程”:完全公平调度策略(CFS)—— 普通门诊

    • 对于“实时进程”:急诊,但是急诊还分为:普通急诊 和 最急的急诊:

        • 普通急诊:SCHED_RR:

          • 同优先级的实时进程中,每个进程又是通过获得时间片来分时运行。

          • 不同优先级的实时进程,高优先级的实时进程能够抢占低优先级的实时进程。

        • 最急的急诊:SCHED_FIFO:

          • 同优先级的实时进程,后进入的进程要等前一个进程释放了CPU,才能够运行。(没有时间片)

          • 不同优先级的实时进程,高优先级的实时进程能够抢占低优先级的实时进程。

1694401084514128.png

而对于Linux操作系统来说,线程的本质就是一个轻量级的进程,所以,线程调度也就是进程调度;而对于Java来说,一个用户态线程也就是对应了一个内核态线程(关于这部分,还是看我上面的那一片文章吧!)

但新的问题就又出现了,进程拥有太多的资源,进程的创建、切换、销毁,都会占用很长的时间,CPU虽然利用起来了,但如果进程过多,CPU有很大的一部分都被用来进行进程调度和进程(线程)切换了。

1694402124299030.png

3、协程来再次提高CPU利用率(减少进程切换):

多进程、多线程已经提高了系统的并发能力,但是在当今互联网高并发场景下,为每个任务都创建一个线程是很不现实的:

  • 会消耗大量的内存:

    • 进程:进程虚拟内存(4GB = 1GB内核空间 + 3GB用户空间)

    • 线程:每个线程启动前后,会消耗1MB左右的内存;

  • 大量的进程/线程调度浪费CPU资源:

  • 最开始的协程模型和线程模型是一样的,线程:协程 = 1:N,即一个用户态的线程/协程 对应一个内核态线程:

1694402835194916.png 1694402878556958.png
进程:线程 1:1模型 进程:协程 1:1模型

思考:既然一个协程(co-routine)可以绑定一个线程(thread),那么能不能将多个协程(co-routine)同时绑定到一个线程(thread)上呢?

  • 于是就有了 线程:协程 = 1:N 模型:

    • 优点:协程在用户态线程即完成切换,不会陷入到内核态,这种切换非常的轻量快速;

    • 缺点:1个进程的所有协程都绑定在1个线程上,某个程序用不了硬件的多核加速能力,一旦某协程阻塞,造成线程阻塞,本进程的其他协程都无法执行了,降级为单线程模型;

1694403114496699.png

思考:我们可否让一个用户程序的多个协程,通过协程调度器,同时绑定到多个内核现呈上呢?

  • 于是,就有了我们最终的 线程:协程 = M:N 模型:

1694403682792883.png

此 M:N 模型克服了以上 1:1,1:N 两种模型的缺点,但是实现也变得更加的复杂,压力给到“协程调度器”!

4、Golang中的协程Goroutine:

Go中,协程被称为goroutine,它非常轻量,一个goroutine只占大概4KB,并且这4KB就足够goroutine运行完,这就能在有限的内存空间内支持大量goroutine,支持了更多的并发。

虽然一个goroutine的栈只占几KB,但实际是可伸缩的,如果需要更多内容,runtime会自动为goroutine分配。

Goroutine特点:

  • 占用内存更小(大概4kb);

  • 调度更灵活(runtime调度);


二、Goroutine协程调度器

Golang种出现过2种协程调度器模型,GM 和 GMP;

在12年的go1.1版本之前用的都是GM模型,但是由于GM模型性能不好,饱受用户诟病。之后官方对调度器进行了改进,变成了我们现在用的GMP模型;

网上关于GM 和GMP的对比介绍文章也很多,随便贴一个:GM到GMP,Golang经历了什么?

1、简单快速地认识下 GM 模型:

GM模型中的G全称为Goroutine协程,M全称为Machine内核级线程!

1694410174636362.png

根据上图。我们很容易知道,M线程 想要 执行/放回 G 都必须访问 全局G队列 ,并且M有多个,而我们知道,为了确保安全,多线程访问同一资源时需要加锁,所以全局G队列是有互斥锁进行保护的。

显然,这种模型有很严重的缺点:

  • 激烈的锁竞争线程M在 创建、销毁、调度G都需要先获取锁;

  • 很差的程序局部性:比如当G中包含创建新协程的时候,比如新协程叫G'为了继续执行G,就需要将G'交给另一个线程M' 执行,这就造成了很差的程序局部性,因为G'和G是相关的,最好放在M上执行,而不是其他M';

  • 系统调用线程切换开销大CPU在M之间的切换,导致频繁的线程阻塞和取消阻塞操作增加了系统开销(我们知道线程切换是需要用户态/内核态转换的,开销是很大的);

2、重点学习下 GMP 模型:

G全称为Goroutine协程,M全称为Machine内核级线程,P全称为Processor协程运行所需的资源!

他在GM模型的基础上增加了一个P层,下面我们来看一下他是如何设计的:

1694411337563637.png

  • M列表:go程序启动时,会设置M的最大数量,默认10000。但是内核很难创建出如此多的线程,因此默认情况下M的最大数量取决于内核。也可以调用runtime/debug中的SetMaxThreads函数,手动设置M的最大数量;

  • P列表:所有的P都在程序启动时创建,并保存在数组中,最多有GOMAXPROCS(可配置)个

  • P的本地队列:P内置的G队列,存的数量有限,不超过256个;

  • 全局队列:当P中的本地队列中有协程G溢出时,会被放到全局队列中;

    线程M想要运行协程G,都是找P索要,一个P一次只能提供一个G协程任务;整个程序最终的协程并行度,其实就是P的数量,即GOMAXPROCS设置的值!

  • 本地队列优先:当队列P1中的G1在运行过程中新建G2时,G2优先存放到P1的本地队列中,如果队列满了,则会把P1队列中一半的G移动到全局队列中;

  • 工作窃取机制:如果P的本地队列为空,那么他会先到全局队列中获取G,如果全局队列中也没有G,则会尝试从其他线程绑定的P中偷取一半的G;

M通过P获得G的优先级顺序:本地队列(无锁)->全局队列(锁)->netpoll事件->去其他队列里偷(CAS,无锁)

3、GMP调度器的设计策略详解:

  • 复用线程:

    • work stealing机制:工作窃取机制,上面已经讲解了;

    1694412904109844.png

    • hand off机制:分手机制,当本线程因为G进行系统调用阻塞时,如读写磁盘,线程释放绑定的P,把P转移给其他空闲的线程执行;

    1694412793594699.png

  • 利用并行:

    • 可以通过配置GOMAXPROCS限定P的个数

  • 抢占:

    • 现在的调度器几乎都是抢占式的,非抢占式的

  • 全局G队列:

    • 相当于是对work stealing机制的一个补充,当本地队列满了后,就将溢出的G放到全局队列中!

    • 因为全局队列是共享的,所以 放/取 都是要加锁的,性能相当于P的本地队列更慢,这也就是为什么“本地队列优先”!

4、通过go func() 启动一个goroutine的过程详解:

1694418639551655.png

  • 在Golang中想创建一个goroutine协程非常容易,只需要在 func() 方法调用前,加上 go 关键字即可;

  • 新创建的goroutine会优先保存在P的本地队列中,如果本地队列满了,则会放在全局队列中;

  • G(goroutine)只能运行在M(Machine)中,M:P = 1:1,当M需要运行G时,不会自己去取,而是找P(Processor)要;

  • P获取G的优先级是:本地队列 —> 全局队列 —> 其他P的本地队列窃取;

  • M执行G的过程是一个无限循环;

  • 如果M在执行G的过程中,遇到了阻塞操作,则会触发“hand off机制”,让P与当前M分手,新建或从休眠M队列中获取一个M线程,将刚刚的P与新得到的M线程进行绑定,继续后续G的处理工作!

  • 当刚刚由于阻塞操作,与P分手的M完成阻塞操作后,由于刚刚的G还没有被完全执行完成,所以它会去找一个空闲的P,将这个G放到空闲P的本地队列中;如果获取不到空闲P,那么M线程将变成休眠状态,添加到休眠M队列中,同时将G放入全局队列中

5、P 和 M 的数量问题?

  • P的数量:由启动时环境变量$GOMAXPROCS或者是由runtime的方法GOMAXPROCS()决定,这意味着在程序执行的任意时刻都只有$GOMAXPROCS个goroutine在同时运行;

  • M的数量:

    • go语言本身的限制,go程序启动时,会设置M的最大数量,默认10000,但是内核很难支持这么多的线程数,所以这个限制可以忽略;

    • 调用runtime/debug中的SetMaxThreads(),设置M的最大数量;

    • 一个M阻塞了,会创建新的M;

6、P 和 M 何时被创建?

  • P何时被创建(饿汉):在确定了P的最大数量n后,运行时系统会根据这个数量创建n个P —— 程序启动时,即创建好

  • M何时被创建(懒汉):没有足够的M来关联P并运行其中的可运行的G;比如所有的M此时都阻塞住了,而P中还有很多就绪任务,就会去寻找空闲的M,而没有空闲的,就会去创建新的M。

jiguiquan@163.com

文章作者信息...

留下你的评论

*评论支持代码高亮<pre class="prettyprint linenums">代码</pre>

相关推荐