golang 调度器的由来
单进程时代
最早期一切程序都是串行的,不需要调度器
早期系统是单线程的,单线程的系统在执行多个任务的时候,因为是单一流程执行,所以cpu在处理C任务的时候其他任务都会处于阻塞状态。
多进程/线程时代
后来进入多进程/线程时代的时候就有了调度去,解决了阻塞问题。
但是当进程拥有太多的资源的时候,进程的创建、切换、销毁都会占用很长的时间,cpu虽然利用起来了,但是如果进程太多,cpu大多数时间都在进行进程的调度。
在linux下cpu对进程和线程的态度是一样的。
如何来提供cpu的利用率呢
协程时代
多进程/线程解决了系统的并发能力,但是在当今的互联网高并发的情况下,为每个任务创建一个进程/线程是不现实的,因为要消耗大量的内存和调度时cpu的高消耗。
- 进程的栈空间在几 MB 到几十 MB 之间。
- 线程的栈空间在几 KB 到几 MB 之间。
- 协程的栈空间在几 KB。
其实一个线程分为内核态线程和用户态线程。一个用户态线程必须要绑定一个内核态线程,CPU并不知道有“用户态线程”的存在,它只知道它运行的是一个“内核态线程”(Linux的PCB进程控制块)。
我们再去细化去分类一下,内核线程依然叫“线程(thread)”,用户线程叫“协程(co-routine)".
如图一个线程绑定一个协程,那么N个协程能否绑定一个线程呢?当然是可以的。
N:1的关系:
N个协程绑定一个线程
优点:是协程在用户态线程既可以完成切换,不会陷入到内核态,这总切换非常的轻量快速。
缺点:如果某一个协程阻塞,会造成线程阻塞,该线程的其他协程都无法执行,丧失了并发能力。
1:1
1个协程绑定一个线程,这种最容易实现。协程的调度都由CPU完成了,不存在N:1缺点
缺点:协程的创建、删除和切换的代价都由CPU完成,有点略显昂贵了。
M:N
M个协程绑定N个线程,是N:1和1:1的结合,克服了上诉的两种模式的缺点。
协程跟线程是有区别的,线程由CPU调度是抢占式的,协程由用户态调度是协作式的,一个协程让出CPU后,才执行下一个协程。
Goroutine
go提供了更容易使用的并发方法,使用了goroutine和channel。goroutine来自协程的概念,让一组可复用的函数运行在一组线程上,即使有协程阻塞,该线程的其他协程也可以被runtime调度到其它可以运行的线程上执行。
go中,协程被称为goroutine,它非常轻量,一个goroutine一般只有几kb,这几kb足以让goroutine运行完,这就能在有限的内存空间内支持大量goroutine,支持了更多的并发。
虽然一个goroutine的栈只占几KB,但实际是可伸缩的,如果需要更多内容,runtime会自动为goroutine分配。
goroutine的特点:
- 占用内存更小(几kb)
- 调度更灵活(runtime调度)
go最早的调度模型GM(被废弃)
g:goroutine 协程
m:thread 线程
gm模型是如何调度的
M(线程)有多个,线程在执行的时候需要访问全局的G队列,这样多个线程访问同一个资源需要通过加锁来保证互斥/同步,所以全局的G队列是有互斥锁进行保护的。
GM调度器有几个缺点:
- 创建,销毁,调度goroutine的时候每个M(线程)都要去获取锁,这就形成了激烈的锁竞争。
- 如果有协程阻塞的时候M(线程)转移goroutine的时候会造成延迟和额外的系统负载。
- 系统调度线程的时候,过于频繁的线程阻塞和取消阻塞造成了系统额外的开销。
go现在的调度模型GMP
现在的调度模型除了M(线程)和Goroutine之外,多了一个Processor(处理器)。
g:goroutine 协程
m:thread 线程
processor:它包含了运行goroutine的资源,如果线程想运行gorotine,必须先获取p,p中还包含了可运行的G队列
在GMP模型中,线程是运行goroutine的实体,调度器的功能是把可运行的gorotine分配到工作线程上。
- 全局队列(global queue):存放等待运行的G
- P的本地队列: 同全局队列类似,存放的也是等待运行的G,存的数量有限,不超过256个,新创建了一个G1时,G1优先加入到p的本地队列中,如果队列满了,则会把本地队列中的一半的G移动到全局队列。
- P列表:所有的P都在程序启动的时候创建,并保持在数组中,最多有GOMAXPROCS个,GOMAXPROCS可以通过配置来设定。
- M: 线程想运行任务就的获取P,从P的本地队列获取G,P队列为空时,M也会尝试从全局队列拿一批G放到P的本地队列,或者从其他P的本地队列偷一般放到自己的P的本地队列。M运行G,当前G执行完以后M会从P获取下一个需要执行的G。
- goroutine调度器和系统调度器是通过M(线程)结合起来的,每个M都代表一个内核线程,系统调度器负责把内核线程分配到CPU的核上执行。
P和M的个数 - P的数量由启动时环境变量$GOMAXPROCS或者是由runtime的方法GOMAXPROCS()决定。
- M的数量,go程序启动时,会设置M的最大数量,默认10000.但是内核很难支持这么多的线程数,所以这个限制可以忽略,也可以通过SetMaxThreads函数,设置M的最大数量。一个M阻塞了,会创建新的M
P和M什么时候创建 - P在系统运行时会根据GOMAXPROCS的个数创建n个P
- M在没有足够的M来关联P并运行其中的G的时候,例如所有的M此时都阻塞,P队列中又有很多就绪的任务,这时回去寻找空虚的M,没有空闲的M就回去创建新的M出来绑定当前的P来消耗任务。
调度策略
线程频繁的创建,销毁是一个极大的消耗,go设计了复用线程。
- work stealing机制(偷)
当前线程无可用的G时,会尝试从其他线程绑定的P中偷取G,而不是销毁线程。 - hand off机制
当本线程因为G运行阻塞时,线程会释放绑定的P,把P转移给其他闲置的线程执行。
利用并行
GOMAXPROCS设置P的数量,最多有GOMAXPROCS个线程分布在多个CPU上同时运行。GOMAXPROCS也限制了并发的程度,比如GOMAXPROCS = 核数/2,则最多利用了一半的CPU核进行并行。
抢占
在coroutine中要等待一个协程主动让出cpu才能执行下一个协程,在go中一个goroutine最多占用cpu 10ms,防止其他goroutine被饿死。
全局G队列
在新的调度器中依然有全局G队列,但是被弱化了,只有M无法从其他P队列中无法偷取到G的时候,才会从全局G队列中获取G。
go func() 调度流程
- 我们通过go func() 来创建一个goroutine;
- 有两个存储G的队列,一个p本地队列和一个全局队列,新创建的G会先保存在p的本地队列中,如果p的本地队列已经满了就会保持在全局队列中。
- 一个线程绑定一个p,M会从P的本地列表弹出一个可执行的G来执行,如果P的本地列表为空,就会从其他M绑定的P中偷取一个G,如果其他M的P中没有准备就绪的G就会从全局队列中拉去G。
- 一个M从P从调度可执行的G是一个循环的过程。
- 当M执行G发送syscall或阻塞的时候,M会阻塞,如果当前M的P队列中有准备就绪的G等待执行,runtime会把当前M的P解绑,然后(创建出一个/寻找一个空闲的)M来服务这个P
- 当M因为执行阻塞任务结束时,由于M的P本队队列让摘除掉了,这个时候G会获取一个空闲的P,如果获取不到P,这个线程就会变成休眠状态,加入到空闲线程中,然后这个G会被放入到全局队列中。