面经-MPG并发调度模型

365 阅读4分钟

go协程

协程需要解决线程遇到的几个问题:

  • 内存占用要小,且创建开销要小
  • 减少上下文切换的开销

第一点好实现,用户态的协程,只是一个数据结构,无需系统调用,而且可以设计的很小,达到 KB 级别。

第二点只能减少上下文切换次数来解决,因为协程的本质还是线程,其切换开销在用户态是无法降低的,只能通过降低切换次数来达到总体上开销的减少,可以有如下手段:

  1. 让可执行的线程尽量少,这样切换次数必然会少
  2. 让线程尽可能的处于运行状态,而不是阻塞让出时间片

Go语言为了更易用、高效的并发能力,提出了goroutine的概念。通常情况下,一个goroutine只占用几kb的内存,远低于内核线程(几M),而且goroutine的整个生命周期都是由runtime包进行管理,不需要内核的参与。

  • 并发,就是同时发生,而并行,就是同时进行。
  • 以咖啡店为例,两人排两队同时点咖啡,而接到订单一个咖啡师做两杯咖啡,这是并发两个咖啡师同时工作,每人做一杯咖啡,这是并行。同理回到程序中,多线程程序在单核上运行,就是并发;多线程程序在多核上运行,就是并行

多进程/线程虽然提高了系统的并发能力,但是一个进程/线程也占用了较多的内存(32位操作系统进程虚拟地址空间4GB,线程大约8MB),所以大量的进程/线程会带来以下2个问题:

  • 高内存的占用
  • 调度消耗大量CPU时间片

上面说的线程是内核线程,而程序直接管理的是处于用户态的线程 — 用户线程,也成为co-routine(协程),但是CPU调度的是内核线程,它感知不到用户线程的存在,所以用户线程需要调度器去调度

8d8cd36a6d1e42b4802ddf4683488d04.png

  • 在Go语言中通过自己的调度器实现了CSP模型,这个调度器也就是MPG调度模型: v2-e30e4541eb9324333fe55a307eb762e0_1440w.jpg

image.png

先看 G,取 goroutine 的首字母,主要保存 goroutine 的一些状态信息以及 CPU 的一些寄存器的值,例如 IP 寄存器,以便在轮到本 goroutine 执行时,CPU 知道要从哪一条指令处开始执行。

再来看 M,取 machine 的首字母,它代表一个工作线程,或者说系统线程。G 需要调度到 M 上才能运行,M 是真正工作的人。结构体 m 就是我们常说的 M,它保存了 M 自身使用的栈信息、当前正在 M 上执行的 G 信息、与之绑定的 P 信息……

再来看 P-处理器,用来执行 goroutine,它维护了本地可运行队列,取 processor 的首字母,为 M 的执行提供“上下文”,保存 M 执行 G 时的一些资源,例如本地可运行 G 队列,memeory cache 等。

G 需要在 M 上才能运行,M 依赖 P 提供的资源,P 则持有待运行的 G

image.png

go func()调度流程

v2-08a62309cb2c22fe765c20d2f640e15c_1440w.jpg

1、我们通过 go func()来创建一个goroutine;

2、有两个存储G的队列,一个是局部调度器P的本地队列、一个是全局G队列。新创建的G会先保存在P的本地队列中,如果P的本地队列已经满了就会保存在全局的队列中;

3、G只能运行在M中,一个M必须持有一个P,M与P是1:1的关系。M会从P的本地队列弹出一个可执行状态的G来执行,如果P的本地队列为空,就会想其他的MP组合偷取一个可执行的G来执行;

4、一个M调度G执行的过程是一个循环机制;

5、当M执行某一个G时候如果发生了syscall或则其余阻塞操作,M会阻塞,如果当前有一些G在执行,runtime会把这个线程M从P中摘除(detach),然后再创建一个新的操作系统的线程(如果有空闲的线程可用就复用空闲线程)来服务于这个P;

6、当M系统调用结束时候,这个G会尝试获取一个空闲的P执行,并放入到这个P的本地队列。如果获取不到P,那么这个线程M变成休眠状态, 加入到空闲线程中,然后这个G会被放入全局队列中。

v2-e09c9a41a6cde3f3b7bb8810183d22a3_1440w.jpg