本文主要介绍调度器的由来以及golang调度器为何要如此设计,以及GPM模型解析
一.调度器的由来
1.单进程时代
单进程时代不需要调度器,一切程序都是串行,所以单进程的操作系统会面临这样一个问题:
- 程序只能串行执行,一个进程阻塞了,其他进程啥事也做不了,只能等待,会造成CPU时间的严重浪费
那么能不能有多个进程一起来执行多个任务呢?
答案是可以的,后来操作系统就有了最早的并发能力:多进程并发
多进程并发:当一个进程阻塞的时候,切换到另外等待执行的进程,尽量将CPU利用起来。
2.多进程/多线程时代
多进程或多线程时代就有了调度器的需求,以多进程为例,其会使用CPU调度器来当某个进程阻塞的时候,调度一个合适的进程给CPU。
这种方式解决了阻塞的问题,但也存在一个问题:
- 如果进程数量很多,进程的调度会占用CPU很多的时间(进程创建,销毁,切换等),CPU利用率不高
对比线程,虽然其调度成本会比进程小很多,但实际上多线程程序的开发和设计也比较复杂,而且在当前互联网业务环境下,为每个任务都创建一个线程是不现实的,这会大量的消耗内存(进程占用4G(32位),而线程大约也要4M)
所以,多线程/多进程时代,会面临这样两个问题
- 高内存占用
- 调度的高CPU消耗
但是,其实一个线程可以分为内核态线程和用户态线程,一个用户态线程必须要绑定一个内核态线程,但是CPU并不知道用户态线程的存在,它只知道它运行的是一个内核态线程(Linux的PCB进程控制块)
3.协程时代
我们可以将内核线程依然叫做线程,把用户态线程叫做协程
那么协程和线程就有三种映射关系:
- N : 1 : N个协程,一个线程
- 1 : 1 : 一个协程,一个线程
- N : M : N个协程,M个线程
下面我们分别讨论一下这三种映射关系的优点和缺点:
N : 1 关系
N个协程绑定一个线程
优点:
- 协程在用户态即完成切换,不会陷入到内核态,这种切换非常轻量快速
缺点:
- 无法使用硬件的多核加速
- 一旦协程阻塞,造成线程阻塞,本进程的其他协程就都无法执行了,根本没有并发能力!
1 : 1 关系
一个协程绑定一个线程
优点:
- 容易实现,不存在N比1的缺点
缺点:
- 协程的创建,删除,切换的代价都由CPU完成,代价有点昂贵
M : N 关系
M个协程,N个线程
克服了以上两种模型的缺点,但是实现较为复杂
协程和线程的调度是有区别的,线程是由CPU调度的,是抢占式的,协程是由用户态调度,是协作式的,一个协程让出CPU后,才执行下一个协程
二. goroutine 和 go调度器
1. goroutine
go提供了goroutine。
goroutine来自于协程的概念,让一组可复用的函数运行在一组线程之上,即使有协程被阻塞,该线程的其他协程也可以被runtime调度,转移到其他可运行的线程上,最关键的是,程序员看不到这些底层的细节,这就降低了编程的难度,提供了更容易的并发
goroutine非常的轻量,一个goroutine只占几KB,并且只几KB就足够goroutine运行完,这样就能在有限的内存空间内,支持大量的goroutine,支持更多的并发,虽然一个goroutine的栈只有几KB,但实际是可伸缩的,如果goroutine需要更多的空间,runtime会为goroutine自动分配。
goroutine的特点:
- 占用内存很小(几KB)
- 调度更加灵活(由runtime调度)
2. go调度器的演变历史
go目前使用的调度器是2012年重新设计的,因为之前的调度器存在性能问题,我们先来研究一下废弃的调度器,这样才能更好的了解现有的调度器为何如此设计
先行约定一下,我们采用G来表示goroutine,M来表示线程
废弃的调度器仅有一个全局的go协程队列,所以多个M如果要访问此全局的G队列,都需要加锁,锁的粒度会非常的大,极度的影响调度器的性能,所以我们可以总结一下,老调度器的几个缺点:
- 锁竞争激烈: 每个M要需要加锁访问全局的G队列
- 延迟和额外的系统负载:比如G中创建新的协程的时候,最好是新建的协程能给当前M,而不是其他M,局部性很差
- 系统调用(CPU在M之间切换),导致频繁的系统阻塞和取消阻塞的操作,都增加了系统的开销
正是基于以上缺点的改进,GPM模型的go调度器,诞生了!
三.GPM模型的Go调度器及其设计思想
在GPM模型的go调度器中,除了M和G,又引进了P
- G : 协程
- P : 逻辑处理器,包含了运行goroutine的资源和可运行的G队列
- M : 内核线程,负责运行G
- 全局队列:存放等待运行的G
- P的本地队列:和全局队列类似,存放的也是等待运行的G,但是存放的数量有限,不会超过256个,在G中新建G时,新建的G优先加入P的本地队列,如果队列满了,则把本地队列中一半的G移动到全局队列
- P列表:所有的P都在程序启动时创建,并保证的数组中,最多有GOMAXPROCS个
- M:线程想运行G就得获得P,从P的本地队列获取G,P队列为空时,M会尝试从全局队列拿一批G放到P的本地队列,或从其他P的本地队列偷取一般放入自己P的本地队列
goroutine调度器和OS调度器是通过M结合起来的,每个M都代表了一个内核线程,OS调度器负责把内核线程分配到CPU的核上运行
一.go调度器的设计思想:
1.复用线程
避免频繁的创建,销毁线程,而是对线程进行复用
- work stealing 机制 :当M绑定的P队列中无可运行的G时,尝试从其他M绑定的P队列中偷取G,而不是销毁M
- hand off机制 : 当M进行系统调用而阻塞时,线程释放绑定的P
2.利用并行
GOMAXPROCS设置P的数量,最多有GOMAXPROCS个线程分布在多个CPU上同时运行,GOMAXPROCS同时也限制了并发的程度,比如GOMAXPROCS=核数/2,则最多利用了一半的CPU核进行并行
3.协作调度
在coroutine中要等待一个协程主动让出CPU才执行下一个协程,但是在go中,一个goroutine最多占用CPU 10ms,防止其他的goroutine饿死,这就是goroutine不同于coroutine的一个地方
4.全局G队列
在新的调度器中仍然有全局G队列,但功能已经被弱化了,当M执行work stealing从其他P偷不到G时,它可以从全局G队列获取G
二.启动一个goroutine的调度流程
通过上图,我们可以得到几个结论:
- 通过go关键字来启动一个goroutine
- 有两类G的存储队列,一个是P的局部G队列,一个是全局G队列,新建G会保存在P的本地G队列中,如果P的本地G队列满了就会保存在全局的G队列中
- G只能运行在M中,一个M必须持有一个P,M与P的关系是一比一,M会从P的本地G队列中弹出一个G来执行,如果P的本地队列为空,就会想冲其他的MP组合中偷取G来执行
- 一个M调度G执行的过程是一个循环机制
- 当M执行每一个G的时候如果发生了系统调用或阻塞操作,那么这个M会被阻塞,如果当前有一些G在这个MP组合,runtime会吧这个M从P中摘除,然后再创建一个新的M或者寻找一个空闲的M来服务P
- 当M的系统调用或阻塞操作结束的时候,这个G会尝试获取一个空闲的P,并放入到这个P的本地队列,如果获取不到P,则此M变成休眠状态,加入到空闲M中,然后这个G会被放到全局的G队列中
三.特殊的M0和G0
- M0:M0是启动程序后编号为0的线程,M0负责执行初始化操作和启动第一个G,M0对应的实例会在全局遍历runtime.m0中
- G0:每个M都有自己的G0,G0仅负责调度G,不执行其他任何可执行的函数,每启动一个M,都会创建属于此M的G0
四.有关P和M数量的问题
- P的数量
- 由启动时环境变量\(GOMAXPROCS***或者***runtime***的***GOMAXPROCS***()决定,这意味着在程序执行的任意时刻都只有***\)GOMAXPROCS个goroutine在同时运行
- M的数量
- go语言本身的限制:go程序启动时,会设置M的最大数量,默认10000,但是内核很难支持这么多线程数,所以这个限制可以忽略
- runtime/debug中serMaxThreads函数,设置M的最大数量
- 一个M阻塞了,会创建新的M
M和P的数量没有绝对关系,一个M阻塞,P就会去创建或者切换到另外一个M,所以,即使P的默认数量是1,也有可能会创建很多个M出来
五.P和M何时会被创建
- P何时创建
- 在确定P的最大数量N之后,runtime会根据这个创建N个P
- M何时创建
- 没有足够的M来关联P,并运行其中可运行的G时,比如所有的M都阻塞住了,而P中还有很多待运行的G,就会去寻找空闲的M,没有空闲的M就会去创建新的M
四.总结
Go调度本质是把大量的goroutine分配到少量线程上去执行,并且利用多核并行,实现强大的并发