Golang协程调度器原理 & GMP设计思想

1.Golang调度器的由来

1.1 早期单进程操作系统

1654442246561.png

所有进程只能顺序执行,产生问题:

  • 单一执行流程,计算机只能一个任务一个任务进行
  • 进程阻塞导致CPU浪费 (即某个进程阻塞,会导致后面程序无法继续执行)

1.2 多进程/多线程操作系统

1654442316550.png

  1. 以时间片轮询的机制**并发**执行程序 (并行与CPU核数有关),很显然多线程/多进程可以解决`1.1`中进程阻塞导致CPU浪费的问题(即是你程序阻塞,时间片到了,也会强制释放CPU)。**产生问题:**
  • CPU高消耗
    1654440879537.png
    以时间片轮询机制而言,为了保存原有线程的系统调用或者相关资源环境等,必然涉及到拷贝复制的过程,就会涉及到切换成本,造成CPU浪费。因此线程越多,切换过程就会更加频繁,所以线程并不是越多越好(线程越多抢占CPU会越有利)。
  • 内存高占用
    在32bit操作系统中,一个进程占用虚拟内存4GB;而一个线程占用4MB左右;因此多进程/多线程会导致内存高占用的问题。(而协程占用KB级别)

1.3 协程

  1. 为了解决`1.2`CPU高消耗和内存高占用问题,而内核态无法修改,所以尝试修改用户态。将线程分为用户线程和内核线程。而**内核线程称为线程,用户线程称为协程**,由于CPU视野只有内存空间,因此**协程的开辟对CPU来说是无感的**。

1654442413902.png

  1. 线程通过协程调度器绑定多个协程,**而CPU视野只有内核空间,所以对CPU而言只有单一线程即进程,因此此方法可有效解决CPU高消耗的问题**。

1654442835194.png

  1. 每个语言对协程进行不同处理。Golang对协程进行相应优化:对协程co-routine重命名为goroutine;修改协程内存大小,**每个goroutine只有几KB大小**,因此可以大量创建;**可灵活调度,切换成本较低**。所以最后重点就落到了**优化协程调度器**上面。

2.协程调度器和GMP模型设计思想

2.1 早期协程调度器

1654444610865.png

  1. 各个线程首先需要去全局G队列拿锁,才能去执行协程挂载的任务,此时该线程不释放锁就导致其他线程无法去执行协程上的任务。

缺点:

  • 创建,销毁,调度协程都需要先去获取锁,这就导致形成了激烈的锁竞争
  • CPU在线程之间频繁切换会增加系统开销;

2.2 GMP模型简介

  1. G ------ goroutine协程
  2. P ------ 协程调度器
  3. M ------ 线程
  1. 每个 `P` 保存了当前执行的协程`G`内部资源信息(堆栈地址和变量参数等),所以`M`要先去获取`P`才能去执行`G`。创建的`G`会优先存放在本地队列,如果本地队列满了(最多256`G`),会存放至全局`G`队列。

1654445299463.png

P的个数,可由环境变量中$GOMAXPROCS设置;或在程序中可通过runtime.GOMAXPROCS()设置。

2.3 调度器设计策略

2.3.1 复用线程

复用线程可避免创建与销毁线程中进行的资源消耗;

实现的两种机制:

  • work stealing机制


1654445862179.png

  1. 当线程`M1``P`绑定,正在执行协程`G1`,而此时线程`M2`空闲,此时`M2`的协程调度器`P`会从`M1`的本地协程队列中偷取协程`G`到自己这边执行。
  • hand off机制

1654446475425.png

  1. 当此时`M1``M2`正常执行协程所挂载的任务,突然协程`G1`发生阻塞现象(比如read/write/channel阻塞等),这时系统会尝试唤醒/创建一个线程`M3`(优先唤醒,符合复用线程的思想),并把与当前阻塞线程`M1`绑定的协程调度器`P`转移到新的线程`M3`上,并把原来阻塞线程`M1`所占用的CPU进行释放。后续执行完成后,如果`G1`需要执行会被重新加入队列进行执行,`M1`会被睡眠或者销毁。

2.3.2 利用并行

  1. 可充分发挥多核优势,通过设置`GOMAXPROCS`设置协程调度器的个数,通常并不会挂满,设置为CPU核数/2

2.3.3 抢占

  1. 相较于老的调度器而言,老调度器中只有当当前协程释放CPU,另一个协程才去执行;现在调度器以时间片而言,一个时间片到了后会强制释放CPU给其他协程使用。

2.3.4 全局G队列

  1. 当线程空闲时,会首先从其他线程对应协程本地队列偷取(即work stealing机制),如果偷不到,会从全局G队列进行获取(前提要先去获取锁)。

2.4 “go func()”的历程

1654567756102.png

开始:

  • 1.执行go func() 会创建一个协程G
  • 2.创建的G优先会被调度到创建G线程对应的本地队列,如果本地队列已满,则G会被加入到全局队列;
  • 3.线程M会通过协程调度器P获取协程G执行。执行go func()之前,如果本地队列为空,优先会从其他线程对应的本地队列偷取G执行,即是work sealing机制;若其他线程对应的本地队列为空,则会从全局队列获取G进行执行;
  • 4.以时间片循环执行go func()对应的执行代码;即上图中(4-调度,5-执行,6-时间片返回,时间片到了会重新加入到本地队列)

如执行go func()代码产生阻塞现象:

E521BA76853FE396F278D85FFCE3FEA1.jpg

为了节省资源,提高CPU利用率

  • 5.系统首先会从本地休眠线程队列中唤醒一个线程M来接管当前正阻塞的线程对应的P和本地G队列;如果本地休眠线程队列没有,则会新创建一个线程M,即hand off 机制;阻塞协程G会和当前线程M进行绑定;
  • 6.当阻塞完成后,线程M会被加入到休眠线程队列或者被销毁掉,而G则会被加入到其他本地队列,如果本地队列都满了,则会被加入到全局队列;

2.5 调度器的生命周期

1654604754756.png