深入学习golang之调度器

前言

我们都知道go处理并发能力很强,是因为它有goroutine,go自己实现了一套自己的调度器去调用自己的goroutine。本文就来聊聊go底层的调度器具体工作原理,在介绍调度器之前,我们需要掌握一些跟操作系统的相关概念。

基本概念

进程

进程是具有一定独立功能的程序关于某个数据集合上的一次运行活动,进程是系统进行资源分配和调度的一个独立单位。每个进程都有自己的独立内存空间,不同进程通过进程间通信来通信。由于进程比较重量,占据独立的内存,所以上下文进程间的切换开销(栈、寄存器、虚拟内存、文件句柄等)比较大,但相对比较稳定安全。

线程

线程是进程的一个实体,是CPU调度和分派的基本单位,它是比进程更小的能独立运行的基本单位.线程自己基本上不拥有系统资源,只拥有一点在运行中必不可少的资源(如程序计数器,一组寄存器和栈),但是它可与同属一个进程的其他的线程共享进程所拥有的全部资源。线程间通信主要通过共享内存,上下文切换很快,资源开销较少,但相比进程不够稳定容易丢失数据。

协程

协程是一种用户态的轻量级线程,协程的调度完全由用户控制。协程拥有自己的寄存器上下文和栈。协程调度切换时,将寄存器上下文和栈保存到其他地方,在切回来的时候,恢复先前保存的寄存器上下文和栈,直接操作栈则基本没有内核切换的开销,可以不加锁的访问全局变量,所以上下文的切换非常快。

并发与并行

并发是指两个或多个事件在同一时间间隔发生,是一种逻辑上的概念,并行是指两个或者多个事件在同一时刻发生,是物理上的概念。举个例子:

  顺序执行:老师甲先帮学生A辅导,辅导完之后再取给B辅导,最后再去给C辅导,效率低下 ,很久才完成三个任务

  并发:老师甲先给学生A去讲思路,A听懂了自己书写过程并且检查,而甲老师在这期间直接去给B讲思路,讲完思路再去给C讲思路,让B自己整理步骤。这样老师就没有空着,一直在做事情,很快就完成了三个任务。与顺序执行不同的是,顺序执行,老师讲完思路之后学生在写步骤,这在这期间,老师是完全空着的,没做事的,所以效率低下。

  并行:直接让三个老师甲、乙、丙三个老师“同时”给三个学生辅导作业,也完成的很快。

  单核:只有一个老师给三个学生辅导。

  多核:两个或两个以上的老师同时辅导。

它们是如何协同工作

那进程,线程,协程在我们的操作系统里面是怎样的存在呢?我们可以把操作系统比作是一个只有一台发电机,并且电量刚好满足一条生产线的工厂。这个工厂里面有多个车间,每个车间里面有多个工人,每个车间负责生产流水线不同的部件。那么把工厂比作是一台计算机的话,每个车间相当于一个进程,车间有自己的空间,相当于进程有自己的存储单元。每个进程间相互独立工作互不影响,相当于一个进程挂了不会影响其他进程。车间里有多个工人,每个人又有自己的工作,相当于线程。这些工人共享一个车间位置,相当于同一个进程了的线程共享内存单元。一个工人手工去完成任务效率有点低,如果它又可以同时操控多台机器,那机器相当于协程。哪天工厂又买了一台发电机,增加了一条一样的生产线,就如计算机增加了一个核。

掌握了以上概念之后,后面讲go调度器的时候涉及到的一些名词就比较容易理解了。

go为什么要有自己的调度器

1.POSIX线程API是对已有的UNIX进程模型的逻辑扩展,因此线程和进程在很多方面都类似。例如,线程有自己的信号掩码,CPU affinity(进程要在某个给定的 CPU 上尽量长时间地运行而不被迁移到其他处理器的倾向性),cgroups。但是有很多特性对于Go程序来说都是累赘。 2. 另外一个问题是基于Go语言模型,OS的调度决定并不一定合理。例如,Go的垃圾回收需要内存处于一致性的状态,这需要所有运行的线程都停止。垃圾回收的时间点是不确定的,如果仅由OS来调度,将会由大量的线程停止工作。

2.单独开发一个Go的调度器能让我们知道什么时候内存处于一致性的状态。也就是说,当开始垃圾回收时,运行时只需要为当时正在CPU核上运行的那个线程等待即可,而不是等待所有的线程。

总之一句话就是,go觉得依靠操作系统的调度太慢了。

高级语言对内核线程的封装实现

N:1模型:N个用户空间线程在1个内核空间线程上运行。优势是上下文切换非常快但是无法利用多核系统的优点。

1:1模型:1个内核空间线程运行一个用户空间线程。这种充分利用了多核系统的优势但是上下文切换非常慢,因为每一次调度都会在用户态和内核态之间切换。(POSIX线程模型(pthread),Java)

M:N模型: 每个用户线程对应多个内核空间线程,同时也可以一个内核空间线程对应多个用户空间线程。这样结合了以上两种模型的优点,但缺点就是调度的复杂性。

Go语言采用两级线程模型,即用户线程与内核线程KSE(kernel scheduling entity)是M:N的。最终goroutine还是会交给OS线程执行,但是需要一个中介,提供上下文。这就是G-M-P模型。

  • G: goroutine, 类似进程控制块,保存栈,状态,id,函数等信息。G只有绑定到P才可以被调度。
  • M: machine, OS线程,绑定有效的P之后,进入调度。
  • P: 逻辑处理器,保存各种队列G。对于G而言,P就是cpu core。对于M而言,P就是上下文。可以把它看作是一个局部的调度器,让Go代码跑在一个单独的线程上。P的数量由GOMAXPROCS设置,最大256。

Go调度器有两个不同的运行队列:
– GRQ,全局运行队列,尚未分配给P的G
– LRQ,本地运行队列,每个P都有一个LRQ,用于管理分配给P执行的G。

如上图所示,每个线程运行了一个goroutine(G),所以必须得维持一个上下文P。上下文的数量由启动时环境变量$GOMAXPROCS或者是由runtime的方法GOMAXPROCS()决定(默认值为1)。这意味着在程序执行的任意时刻都只有$GOMAXPROCS个goroutine在同时运行。

灰色的goroutine没有在运行,等待被调度。它们被维护在一个队列(runqueues)里。当一个go语句执行,就将一个新的goroutine添加到队列尾;当运行当前goroutine到调度点时,就从队列中弹出一个新的goroutine。

每一个context拥有一个局部的runqueue。之前版本的Go调度器只有一个全局的带有互斥锁的runqueue,这样线程经常被阻塞等待其它线程解锁,在多核机器上性能表现及其差。

之所以要维护多个context,是因为当一个OS线程被阻塞时,我们可以把contex移到其它的线程中去。

调度
调度的目的就是防止M堵塞,空闲,系统进程切换。

异步调用
Linux可以通过epoll实现网络调用,统称网络轮询器N(Net Poller)。

1.G1在M上运行,P的LRQ有其他3个G,N空闲;
2.G1进行网络IO,因此被移动到N,M继续从LRQ取其他的G执行。比如G2就被上下文切换到M上;
3.G1结束网络请求,收到响应,G1被移回LRQ,等待切换到M执行。

同步调用
文件IO操作

1.G1在M1上运行,P的LRQ有其他3个G;
2.G1进行同步调用,堵塞M;
3.调度器将M1与P分离,此时M1下只有G1,没有P。
4.将P与空闲M2绑定,并从LRQ选择G2切换
5.G1结束堵塞操作,移回LRQ。M1空闲备用。

任务窃取
上面都是防止M堵塞,任务窃取是防止M空闲。

1.两个P,P1,P2
2.如果P1的G都执行完了,LRQ空,P1就开始任务窃取。
3.第一种情况,P2 LRQ还有G,则P1从P2窃取了LRQ中一半的G
4.第二种情况,P2也没有LRQ,P1从GRQ窃取。

g0
每个M都有一个特殊的G,g0。用于执行调度,gc,栈管理等任务,所以g0的栈称为调度栈。g0的栈不会自动增长,不会被gc,来自os线程的栈。

源码

go1.13\src\runtime\proc.go

new

1.获取当前G
2.获取当前G的P
3.从P的gfree中获取G,避免重新创建,有点池化的意思
4.如果没有可复用的G,就重新创建,参数表示stack大小,起始2KB,支持动态扩容
5.将G入队,放入P的LRQ中;由于有工作窃取机制,其他P可以从这个P窃取G
6.如果runq满了(长度256),就放入GRQ中,在sched中
7.尝试加入额外的P去执行G

start
G没办法自己运行,必须通过M运行。

M通过通过调度,执行G。

schdule

从M挂载P的runq中找到G,执行G。

发表评论

电子邮件地址不会被公开。 必填项已用*标注