Goroutine调度原理

448 阅读6分钟

进程、线程、协程

多进程

多进程并发,当一个进程阻塞的时候,切换到另外等待执行的进程,这样就能尽量把CPU利用起来,CPU就不浪费了。

在多进程时代,有了时间片的概念,进程按照调度算法分时间片在 CPU 上执行,A、B、C 三个进程按照时间片并发执行。(调度算法)

goroutine_2.png 这样做有两个优点:

  1. 对于单个核可以并发执行多个进程,应用场景更加丰富,
  2. 当某个进程 IO 阻塞时,也能保证 CPU 的利用率。

但是随着时代的发展,CPU 通过进程来进行调度的缺点也越发的明显。

进程切换需要:

  1. 切换页目录以使用新的地址空间
  2. 切换内核栈和硬件上下文

因为进程拥有太多资源,在创建、切换和销毁的时候,都会占用很长的时间,CPU虽然利用起来了,但CPU有很大的一部分都被用来进行进程调度了。

多线程

对于线程和进程,我们可以这么理解:

  • 当进程只有一个线程时,可以认为进程就等于线程。
  • 当进程拥有多个线程时,这些线程会共享相同的虚拟内存和全局变量等资源。这些资源在上下文切换时是不需要修改的。
  • 线程也有自己的私有数据,比如栈和寄存器等,这些在上下文切换时也是需要保存的。

goroutine_3.png

线程是CPU调度的最小单位, 进程是资源分配的最小单位。

  • 进程:进程是资源分配的最小单位,进程在执行过程中拥有独立的内存单元。
  • 线程:线程是CPU调度的最小单位,线程切换只须保存和设置少量寄存器的内容。

虽然线程比较轻量,但是在调度时也有比较大的额外开销。每个线程会都占用 1M 以上的内存空间,在切换线程时不止会消耗较多的内存,恢复寄存器中的内容还需要向操作系统申请或者销毁资源。

协程

协程作为用户态线程,也是轻量级的线程,用来解决高并发场景下线程切换的资源开销。

线程分为内核态线程和用户态线程,用户态线程需要绑定内核态线程,CPU并不能感知用户态线程的存在,它只知道它在运行1个线程,这个线程实际是内核态线程。

用户态线程实际有个名字叫协程(co-routine),为了容易区分,我们使用协程指用户态线程,使用线程指内核态线程。

协程跟线程是有区别的

  • 线程/进程是内核进行调度,有 CPU 时间片的概念,进行 抢占式调度(有多种调度算法)
  • 协程 对内核是透明的,也就是系统并不知道有协程的存在,是完全由用户自己的程序进行调度的,因为是由用户程序自己控制,那么就很难像抢占式调度那样做到强制的 CPU 控制权切换到其他进程/线程,通常只能进行 协作式调度,需要协程自己主动把控制权转让出去之后,其他协程才能被执行到。

goroutine优势

goroutine 的存在是为了换个方式解决操作系统线程的一些弊端

  1. 创建和切换太重 操作系统线程的创建和切换都需要进入内核,而进入内核所消耗的性能代价比较高,开销较大;
  2. 内存使用太重 内核在创建操作系统线程时默认会为其分配一个较大的栈内存,内核在创建操作系统线程时默认会为其分配一个较大的栈内存,同时会有溢出的风险;

goroutine的优势也就是开销小

  1. goroutine是用户态线程,其创建和切换都在用户代码中完成而无需进入操作系统内核,所以其开销要远远小于系统线程的创建和切换;
  2. goroutine启动时默认栈大小只有2k,这在多数情况下已经够用了,即使不够用,goroutine的栈也会自动扩大,同时,如果栈太大了过于浪费它还能自动收缩,这样既没有栈溢出的风险,也不会造成栈内存空间的大量浪费。

Go协程调度原理

调度器架构

Go的调度器从最开始的单线程经过不断的改进、优化,发展到现在的GMP模型,在GMP模型中有三个重要的结构:

  • G(Goroutine):go协程,一个可执行单元,调度器作用就是对所有G的切换
  • M(Thread):操作系统上的线程,G运行与M上,一个G可能由多个不同的M运行,一个M可以运行多个G
  • P(Processor):处理器,他包含了运行G的资源,如果线程M想运行G,必须先获取P,P还包含了可运行的G队列。一个M一个时刻只拥有一个P,M和P的数量是1:1的。

image.png

GMP模型架构

上图中各个模块的作用如下:

  1. 全局队列:存放等待运行G
  2. P的本地队列:和全局队列类似,存放的也是等待运行的G,存放数量上限256个。新建G时,G优先加入到P的本地队列,如果队列满了,则会把本地队列中的一半G移动到全局队列
  3. P列表:所有的P都在程序启动时创建,保存在数组中,最多有GOMAXPROCS个,可通过runtime.GOMAXPROCS(N)修改,N表示设置的个数

M是Goroutine调度器和操作系统调度器的桥梁,每个M代表一个内核线程,操作系统调度器负责把内核线程分配到CPU的核心上执行。

调度策略

复用线程

调度器核心思想是尽可能避免频繁的创建、销毁线程,对线程进行复用以提高效率。
1. work stealing机制(窃取式)
当本线程无G可运行时,尝试从其他线程绑定的P窃取G,而不是直接销毁线程。
2. hand off机制
当本线程M因为G进行的系统调用阻塞是,线程释放绑定的P,把P转移给其他空闲的M'执行。

利用多核CPU并行

GOMAXPROCS设置P的数量,最多有GOMAXPROCS个线程分布在多个CPU核心上运行。

抢占

一个goroutine最多占用CPU10ms,防止其他goroutine等待太久得不到执行被“饿死”。

全局G队列

全局G队列是有互斥锁保护的,访问需要竞争锁,新的调度器将其功能弱化了,当M执行work stealing从其他P窃取不到G时,才会去全局G队列获取G。

参考:juejin.cn/post/704474…