Golang协程调度器原理 & GMP设计思想
1.Golang调度器的由来
1.1 早期单进程操作系统
所有进程只能顺序执行,产生问题:
- 单一执行流程,计算机只能一个任务一个任务进行
- 进程阻塞导致CPU浪费 (即某个进程阻塞,会导致后面程序无法继续执行)
1.2 多进程/多线程操作系统
以时间片轮询的机制**并发**执行程序 (并行与CPU核数有关),很显然多线程/多进程可以解决`1.1`中进程阻塞导致CPU浪费的问题(即是你程序阻塞,时间片到了,也会强制释放CPU)。**产生问题:**
- CPU高消耗
以时间片轮询机制而言,为了保存原有线程的系统调用或者相关资源环境等,必然涉及到拷贝复制的过程,就会涉及到切换成本,造成CPU浪费。因此线程越多,切换过程就会更加频繁,所以线程并不是越多越好(线程越多抢占CPU会越有利)。 - 内存高占用
在32bit操作系统中,一个进程占用虚拟内存4GB;而一个线程占用4MB左右;因此多进程/多线程会导致内存高占用的问题。(而协程占用KB级别)
1.3 协程
为了解决`1.2`的CPU高消耗和内存高占用问题,而内核态无法修改,所以尝试修改用户态。将线程分为用户线程和内核线程。而**内核线程称为线程,用户线程称为协程**,由于CPU视野只有内存空间,因此**协程的开辟对CPU来说是无感的**。
线程通过协程调度器绑定多个协程,**而CPU视野只有内核空间,所以对CPU而言只有单一线程即进程,因此此方法可有效解决CPU高消耗的问题**。
每个语言对协程进行不同处理。Golang对协程进行相应优化:对协程co-routine重命名为goroutine;修改协程内存大小,**每个goroutine只有几KB大小**,因此可以大量创建;**可灵活调度,切换成本较低**。所以最后重点就落到了**优化协程调度器**上面。
2.协程调度器和GMP模型设计思想
2.1 早期协程调度器
各个线程首先需要去全局G队列拿锁,才能去执行协程挂载的任务,此时该线程不释放锁就导致其他线程无法去执行协程上的任务。
缺点:
- 创建,销毁,调度协程都需要先去获取锁,这就导致形成了激烈的锁竞争;
- CPU在线程之间频繁切换会增加系统开销;
2.2 GMP模型简介
G ------ goroutine协程
P ------ 协程调度器
M ------ 线程
每个 `P` 保存了当前执行的协程`G`内部资源信息(堆栈地址和变量参数等),所以`M`要先去获取`P`才能去执行`G`。创建的`G`会优先存放在本地队列,如果本地队列满了(最多256个`G`),会存放至全局`G`队列。
P
的个数,可由环境变量中$GOMAXPROCS
设置;或在程序中可通过runtime.GOMAXPROCS()
设置。
2.3 调度器设计策略
2.3.1 复用线程
复用线程可避免创建与销毁线程中进行的资源消耗;
实现的两种机制:
work stealing
机制
当线程`M1`和`P`绑定,正在执行协程`G1`,而此时线程`M2`空闲,此时`M2`的协程调度器`P`会从`M1`的本地协程队列中偷取协程`G`到自己这边执行。
hand off
机制
当此时`M1`和`M2`正常执行协程所挂载的任务,突然协程`G1`发生阻塞现象(比如read/write/channel阻塞等),这时系统会尝试唤醒/创建一个线程`M3`(优先唤醒,符合复用线程的思想),并把与当前阻塞线程`M1`绑定的协程调度器`P`转移到新的线程`M3`上,并把原来阻塞线程`M1`所占用的CPU进行释放。后续执行完成后,如果`G1`需要执行会被重新加入队列进行执行,`M1`会被睡眠或者销毁。
2.3.2 利用并行
可充分发挥多核优势,通过设置`GOMAXPROCS`设置协程调度器的个数,通常并不会挂满,设置为CPU核数/2。
2.3.3 抢占
相较于老的调度器而言,老调度器中只有当当前协程释放CPU,另一个协程才去执行;现在调度器以时间片而言,一个时间片到了后会强制释放CPU给其他协程使用。
2.3.4 全局G队列
当线程空闲时,会首先从其他线程对应协程本地队列偷取(即work stealing机制),如果偷不到,会从全局G队列进行获取(前提要先去获取锁)。
2.4 “go func()”的历程
开始:
- 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()
代码产生阻塞现象:
为了节省资源,提高CPU利用率
- 5.系统首先会从本地休眠线程队列中唤醒一个线程
M
来接管当前正阻塞的线程对应的P
和本地G
队列;如果本地休眠线程队列没有,则会新创建一个线程M
,即hand off 机制
;阻塞协程G
会和当前线程M
进行绑定; - 6.当阻塞完成后,线程
M
会被加入到休眠线程队列或者被销毁掉,而G
则会被加入到其他本地队列,如果本地队列都满了,则会被加入到全局队列;