Golang调度器原理解析

本文主要介绍调度器的由来以及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***()决定,这意味着在程序执行的任意时刻都只有***\)GOMAXPROCSgoroutine在同时运行
  • 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分配到少量线程上去执行,并且利用多核并行,实现强大的并发