Go调度器的由来
单进程时代
单进程时代是不需要调度器的,每个进程运行完才会进行下一个程序,也就是每个程序都只能串行进行。这样的运行方式存在两个问题
计算机只能一个任务一个任务的执行 进程阻塞时,导致CPU处理停止等待
多进程/多线程时代
多进程和多线程是为了解决单进程带来的阻塞问题,让CPU分时去处理个进程或线程,虽然每次CPU也只能处理一个程序,但是由于在时间片结束后,CPU可以切换到其他进程或线程,从宏观的角度看,似乎是多个进程同时被运行。但是多进程和多线程同时也存在问题
进程拥有太多的资源,进程的创建,切换,销毁都会占用很高的CPU 线程虽然拥有的资源较少,但是同样存在切换资源的消耗,同时多线程还有同步竞争等问题 即多进程和多线程都会占用较高的内存和CPU
协程时代
协程的概念涉及到计算机内核相关知识,这里简单做个介绍。计算机内核分为用户态和内核态,从用户态切换到内核态,需要消耗的资源会更多。多进程和多线程都是属于内核态的系统调用,协程则是为了避免用户从用户态切换到内核态产生的系统调用消耗,而设计的一种用户态的线程。虽然是用户态的线程,但是最终还是需要切换到内核态去运行(如下图所示),所以对于协程和线程,就有多种映射关系,即:一对一,多对一,多对多。

一对一关系
即一个协程绑定一个线程。这种最容易实现,但是也是存在一个问题:
协程的创建,删除和切换的代价都是CPU完成的,消耗依旧很高
多对一关系
即多个协程绑定一个线程。这种协程在用户态就可以完成切换,不会陷入到内核态,这种切换非常轻量。但是也存在问题
某个程序用不了硬件的多核加速的能力 一旦某个协程阻塞,绑定的这个线程同时阻塞,最终导致没有达到并发的效果。
多对多关系
即多个协程绑定多个线程。这种克服了上面两种问题,但是实现起来最为复杂。
最后,协程和线程除了用户态和内核态的区别外,协程由用户态调度是协作式的,线程由CPU调度是抢占式的。一个协程让出CPU后,才执行下一个协程
Go语言的协程
go语言的协程提供了goroutine
和channel
,让一组可复用的函数运行在一组线程之上,即使有协程阻塞,该线程的其他协程也可以被runtime
调度,转移到其他线程上。最关键的是程序员看不到这些细节,降低了编程难度,提供了容易的并发。而且占用内存非常小,一般只有几KB,总结而言:
goroutine
占用内存更小goroutine
调度更加灵活
go语言的协程主要经历了两个发展,一个是GM模型,一个是GMP模型,其中G是goroutine
,M是线程。目前使用的是2012年重新设计的GMP模型
被废弃的goroutine
调度器-GM模型
GM模型原理如下图

M想要执行或放回G,都必须访问全局队列G,并且M有多个,即多线程访问同一资源需要加锁进行保证互斥/同步,所以全局队列G是有互斥锁进行保护的。这种方式有几个缺点:
创建/销毁/调度G都需要每个M获取锁,就会形成激烈的锁竞争 M转移G会造成延迟和额外的系统负载。比如G中包含创建新协程的时候,M创建了G',为了继续执行G,需要吧G'叫个M'执行,也造成了很差的局部性,因为G'和G是相关的,所以最好都放在M上执行,而不是其他的M' 系统调用(CPU在M之间切换)导致频繁的线程阻塞和取消阻塞操作增加了系统开销
GMP模型
针对之前的问题,Go重新设计了调度器,除了G(goroutine)和M(thread)外,还引入了P(Processor)。其中P包含了运行goroutine的资源,如果M想运行G,必须先获取P,P中还包含了可运行的G队列。其主要原理图如下

主要的几个结构有:
全局队列:存放等待运行的G P的本地队列:同全局队列类似,存放的也是等待运行的G,存的数量有限,不超过256个。新建G'时,G'优先加入本地队列,如果队列满了,则把本地队列的一半G移动到全局队列 P列表:所有P都在程序启动时创建,并保存在数组中,最多有 GOMAXPROCS
(可配置)个M:线程想运行任务就要获取P,从P的本地队列获取G,P为空时,M也会尝试从全局队列拿一批G放到本地队列,或者从其他的本地队列拿一半放到自己的本地队列。M运行G,G执行之后,M会从P中获取下一个G,不断重复
Goroutine调度器和OS调度器都是通过M结合起来的,每个M都代表了1个内核线程,OS调度器负责把内核线程分配到CPU的核上执行
其实可以把GMP模型看作是多核CPU的模型。其中M相当于CPU的核心数,P相当于CPU,G相当于进程或线程。只是两者的调度方案是不同的,多核CPU的调度是通过CPU硬件去完成的,而GMP是纯软件的调度。
有关P和M的问题
P的数量:由启动时环境变量 GOMAXPROCS
来确定M的数量:go语言本身的限制。启动时,默认会设置M最大数量(10000),但内核很难支持,所以可以忽略 P何时创建:确定了P的最大数量n后,运行时系统会根据这个数量创建n个P M何时创建:没有足够的M来关联P并运行其中可运行的G。根据需要创建
设计策略
复用线程:可以避免频繁的创建,销毁线程,而是对线程复用。主要通过work stealing和hand off实现 work stealing机制:当本线程无可运行的G时,尝试从其他线程绑定的P偷取G,而不是销毁线程 hand off机制:当本线程因为G进行系统调用阻塞时,线程释放绑定的P,把P转移给其他空闲的线程执行 利用并行: GOMAXPROCS
设置P的数量,可以让多个线程在多个CPU上同时运行抢占:在goroutine中要等待一个协程主动让出CPU才执行下一个协程,一个goroutine最多占用CPU 10ms,防止其他goroutine被饿死 全局G队列:在新的调度器中依然有全局队列,但功能已经被弱化了。当M执行work stealing从其他P拿不到G时,它可以从全局队列获取G
go func()
调度流程
一个go func()
完整的调用过程如图:

我们可以得出结论:
我们通过 go func()
来创建一个goroutine有两个存储G的队列,一个是局部调度器P的本地队列,一个是全局G队列。新创建的G会先保存在本地队列中,如果本地队列已经满了,会保存在全局队列中 G只能运行在M中,一个M必须有一个P,M会从P的本地队列弹出一个可执行状态的G来执行,如果本地队列为空,会从其他队列偷一个来执行 一个M调度G执行的过程是一个循环机制 M执行某一个G时,如果发生了系统调用或其他阻塞操作,M会阻塞,如果当前有一些G在执行,runtime会把这个线程M从P中摘除,然后再创建一个新的线程来服务这个P 当M系统调用结束时,这个G会尝试获取一个空闲的P执行,并放到P的本地队列,如果获取不到P,M就变成休眠状态,放到空闲线程中,然后G会被放入全局队列中




