Background
早期操作系统是单进程的,只能顺序执行进程,如果进程需要IO,必须要等IO结束才能继续运行,造成了严重的CPU资源的浪费。
为了提升CPU利用率,出现了多进程操作系统,当一个进程被阻塞,可以切换到其他进程运行,大大减少了CPU资源的浪费。
但是进程的切换开销较大,为了更好地实现并发,出现了线程。一个进程可以有很多个线程,他们共享进程的地址空间,切换带来的开销也要比进程切换的开销小。
但是线程切换也要涉及用户态和内核态之间的切换,不够轻量级,于是将线程一分为二,分别是用户线程和内核线程。用户线程负责业务上的处理,内核线程负责操作系统层面的处理。
用户线程就称为协程,内核线程还称为线程,协程的调度需要通过协程调度器来实现,协程调度器为内核线程绑定多个协程。
GMP就是Go的goroutine调度模型。
Goroutine内存占用小,一般是几KB,因此可以大量创建;并且可以灵活调度,因为它的切换成本低。
GMP
G. M. P.
- G代表goroutine。G中存放并发执行的代码入口地址、上下文、运行环境(关联的P和M)、运行栈等执行相关的信息。
- M是一个内核线程。是操作系统层面调度和执行的实体。
- P是处理器,是一个抽象的概念,用于处理G,代表M和G所需要的资源。P是一个管理的数据结构,P主要是降低M对G的复杂性,增加一个间接的控制层数据结构。
P持有G的队列,P可以隔离调度,解除P和M的绑定就解除了M对一串G的调用。G并不是执行体,而是存放并发执行体的元信息,包括并发执行的入口函数、堆栈、上下文等信息。为了减少对象的分配和回收,G对象是可以服用,只需要将相关元信息初始化为新值即可。
P的数目默认是CPU核心的数量,M和P的数目差不多,但运行时会根据当前的状态动态地创建M,M有上限值10000;G与P是M:N的关系,M可以成千上万,远远大于N。
线程是运行goroutine的实体,调度器的功能是把可运行的goroutine分配到线程上。
GMP结构
图引自Golang深入理解GPM模型
- 全局队列(Global Queue):存放等待运行的G
- P的本地队列:存放等待运行的G,但是存储的G数量有限,不超过256个。新建G时,G优先加入到P的本地队列,如果队列满,则会把本地队列中的一半G移动到全局队列。
- P列表:所有的P都在程序启动时创建,并保存在数组中。P的数量可以通过环境变量$GOMAXPROCS来设置;或者在程序中通过runtime.GOMAXPROCS()来设置。
- M:线程想要运行任务就得获得P,从P的本地队列中获取G,P队列为空时,M也会尝试从全局队列拿一批G放到本地队列,或者从其他P的本地队列拿一半放到自己的P的本地队列。
- M列表,当前操作系统分配到当前Go程序的内核线程数,Go语言限定M的最大量是10000
- M运行G,G执行之后,M会从P获取下一个G,不断重复下去。M与P是1:1的关系。
调度策略
M和P构成一个运行时环境
- P优先从本地队列中获取goroutine执行
- 之后从全局队列中获取goroutine执行
- 再之后去其他的P的本地队列中steal goroutine执行
- 并不完全按照以上顺序来,会在执行完61个本地goroutine之后,去全局队列尝试拿goroutine执行,避免全局队列中的goroutine饿死
- 当一个线程因为G进行系统调用阻塞时,线程释放绑定的P,把P转移给其他空闲的线程执行,自己只服务当前这个阻塞了的G。本质是不让CPU闲着,阻塞就切换CPU。当阻塞了的G恢复执行后,在当前线程上执行完之后,还想继续执行,会加入到其他的P队列中,而当前线程会睡眠/销毁。
- 抢占,一个goroutine最多占用CPU 10ms,防止其他goroutine被饿死。
当执行go func()时发生了什么?
图引自Golang深入理解GPM模型
- 创建一个Goroutine
- 放入执行go func()的线程对应的P的本地队列中
2.1 如果本地队列已满,则放入全局队列中
- M获取G
- 调度
- 执行,去运行G中的func()函数
5.1 如果执行时,G.func()发生阻塞
5.2 创建一个M或从休眠队列取一个M
5.3 接管当时正在阻塞G的P
- 时间片超时,将G重新放回到队列尾部