天天看点

go调度器的源码级分析

基于GMP模型的调度器是go实现其引以为傲的用户态线程的核心。本文就以GMP调度器为核心分析一下调度流程,顺便分析一下定时器Timer的实现,它和调度器息息相关。

本文的大纲如下:

1.GMP的关键数据结构

2.goroutine的生命周期

3.系统线程的生命周期

4.触发shedule()的时机

5.网络轮询netpoll

6.sysmon守护线程

7.定时器实现

调度器相关的数据结构都在runtime/runtime2.go中,主要有g(goroutine,协程),m(machine,操作系统线程),p(processor,调度器的资源抽象,m争抢p来获取执行权),schedt(scheduler_type,调度器的全局抽象)

以下都是只给出一些关键字段,实际上每个完整数据结构都有几十个字段!

stack: 协程栈的上下界,由lo和hi两个字段组成

_panic: 协程的panic链表,头部的是最内层的panic(也就是外层的panic可能会被abort)

_defer: 协程的defer链表,头部的是最内层defer。不过本文不关心defer和panic,只是想说明defer和panic都是goroutine级别的 

m: 当前g对应的m

sched: 记录了协程的运行时状态,内部包含sp、pc、bp等伪寄存器

atomicstatus: 协程的状态机,共有近十种状态,如idle,dead,runnable,running,syscall,waiting,preempted等

g0: 运行在系统栈上的协程,即负责调度协程的协程

gsignal: 负责响应信号的协程。由上可知一个空闲的m也自带两个g

curg: m当前运行的g,可能为空表示空闲

p: m当前绑定的p。要运行用户代码,必须绑定上一个p。一般p远少于m。

nextp: m刚刚创建或从阻塞转为就绪时,预先绑定的p。真正运行时,就调用acquirep绑定这个p

oldp: m陷入系统调用时会出让p,这里保存这个p,系统调用返回时试图恢复这个p。这是希望利用局部性。

spinning: 是否自旋。如果当前的m是自旋的,而又从本地队列runq和全局队列globalrunq都获取不到可运行的g时,就会从其他p的本地队列尝试窃取g

status: p的状态机

m: p的m。与m的p双向绑定

deferpool: _defer结构体的per-P缓存池。在deferproc创建新_defer时(本文不分析,详见runtime/panic.go)会优先从中分配内存,按内存大小分为五个链表。这也是利用局部性

runq: 当前p的可运行g队列

gFree: 当前p的空闲g队列(即状态为dead的g。g执行完毕后不会销毁,而是等待被复用)

sudogcache: 创建sudog的缓存池,作用同deferpool。sudog代表一个阻塞状态的g,其记录了g所在的阻塞队列等信息,本文不介绍。

timers,timer0When: timers保存了当前p全部定时器,timer0When保存了所有定时器中最近的即将到达的时间。本文会介绍定时器的实现

可见,schedt的这些字段部分是状态统计信息,一些(最后4个)是p中的某些结构的全局版本。

以及第一个lastpoll是上一次进行网络轮询(netpoll)的时间。在讲定时器时会说到

对于用户来说,GMP调度器中距离自己最近的当然是g。那么当我们轻轻松松地敲下go来启动一个g时,golang为我们做了什么呢?

在编译阶段,编译器会把go关键字转换成一个runtime.newproc调用。newproc有两个参数,初始栈大小和go出来的函数指针,这两个都是在编译时就能确定的。接下来详细看一下newproc

注意!本文的代码都是高度简化版,源代码太复杂了,作者太强了,顶礼膜拜!

 先看newproc1具体怎么初始化g

*1 所有新创建的g的pc都从goexit这个ABI(Application Binary Interface)函数所在的位置开始。这样做等效于:当这个g执行完毕时,就会返回到goexit,执行goexit函数。goexit在本节的最后讲

现在g已经加入p的本地队列了,接下来就是等待被调度了!触发调度的方式有多种,之后会讲到。这边先说明一下,调度的主函数是schedule()函数。它的作用是进行一轮调度:尝试获取一个g,并执行它

findrunnable是一个非常复杂的方法,它阻塞直到获取到一个可运行的g。期间还会顺便做一下网络轮询(之后会说)。工作窃取(work-stealing)也是在这个函数里完成的

上面标有***代码是关键,完成了阻塞获取g的逻辑。netpoll和定时器部分后面会讲到

execute是goroutine的真正入口,它的核心逻辑非常简单,就是调用gogo函数,定位到g.sched中保存的运行位置

gogo是一个汇编(go assembly,和普通汇编略有不同)实现的函数,用g.sched里保存的pc、sp等寄存器信息进行跳转

 现在终于开始执行用户代码了!但是这个g依然不能高枕无忧了!有多种方式可以让这个g失去执行权,即让g.m与p解绑,之后会讨论。现在先假设g顺利执行完了,那就会按之前说的,调用goexit

goexit也是个汇编函数,但逻辑比较简单,总之最后调用到goexit0函数

可以看出,g退出时会先进行一些清理工作,最终会再次调用schedule(),开始新一轮调度!这就是调度器能持续不断地运行的原因

此外,执行完毕的g不会被销毁,而是加入了全局的空闲队列。还记得这个空闲队列哪里用到吗?newproc创建g的时候!以此实现g的复用

系统线程(m)是g的运行载体,一个m只能在同一时刻运行一个g。m为了获取代码执行权必须绑定一个p。如果m不在执行用户代码(如进行系统调用),则和p解绑。

启动一个m的入口是startm函数。

以下总结了调用startm的时机:

1. 有新的g可用时。一般是调用newproc和ready(标记一个g为就绪)函数时。前面提到newproc会调用wakep,其中就调的startm,第一个参数为nil,也就是不指定p,而是让m等待空闲的p

2. p移交所有权时。典型的情况就是sysmon守护线程(后面会讲)发现一个p处于syscall状态的时间太长了,就会调用handoffp函数将它的所有权移交,具体在后面讲sysmon的时候会说。其中也会调startm,第一个参数就是这个p本身,表示让m和这个p绑定,也就是p的所有者被移交(handoff)了。

startm中提到,如果在空闲m列表中获取不到m,就会调用newm创建一个。

allocm分配了一个m的空间,进行一些初始化,但还没有和任何系统线程绑定

newm1将m真正和系统线程绑定

在linux环境下,newosproc会使用clone系统调用创建新线程

clone的第四个参数指定线程的入口,是mstart函数的地址,于是目光转向mstart函数,它是真正创建系统线程的入口

mstart先是进行一些栈的初始化,最终进入mstart1函数

  

mstart1将之前设置的nextp与m绑定,使m终于获得了代码执行权!并且立即进入下一轮调度schedule()

注意,这个函数一开始还在g0.sched中保存了运行状态。别忘了schedule()会跳转到下一个g的pc而不会返回,所以,为了能让mstart1返回,必须有类似gogo(&_g_.m.g0.sched)的调用,跳转到mstart1的调用者,才能返回。返回后,进入mexit函数销毁线程。

mexit函数主要就是调用handoffp函数,让即将销毁的m的p转交所有权。然后调用一些系统调用来摧毁线程,没什么好说的。

那么什么时候会有gogo(&_g_.m.g0.sched)的调用呢?我看了半天只找到一处,就是在某个g退出时(调用goexit),会检查这个g有没有锁定(锁定,一般指的是调用公有函数runtime.LockOsThread,使当前g独占某个操作系统线程)过当前m,有的话就会调用这个函数。换句话说,如果g锁定了所在的m,那么就认为这个m不干净了,等g结束后就会跳转到g0.sched从而销毁m。这个g真是坑爹啊!正常情况下,m是不会被销毁的。

最后,在startm中提到会现在空闲m列表中获取m,获取不到再进入newm。这个空闲m列表,其实就是保存了休眠状态下的m。触发垃圾回收时当前m会休眠。此外,当g从系统调用中返回时,g.m会寻找可用的p以继续执行代码,如果没有可用的p,也只能进入休眠了。

之前看到,在启动g或m的时候会立即开始一轮调度。除此之外,触发调度还有一些时机

主要是运行态的g主动成为阻塞态或就绪态。

成为阻塞态就是调用gopark函数挂起自身。gopark函数最终调用park_m函数

成为就绪态就是调用公有函数runtime.GoSched()出让当前g的执行权,有点像java里的Thread.yield()。GoSched最终调用goschedImpl函数

两者最终都调用schedule()开启新一轮调度

在进行系统调用时,编译器会在这个系统调用前后插入reentersyscall和exitsyscall两个钩子函数,在这两个函数里进行GMP状态的保存和恢复

reentersyscall中在解绑m与p时,会将p保存到m.oldp中,在exitsyscall中尝试恢复p,这样做是希望利用局部性。

exitsyscall0是慢路径,因为它由mcall调用,会造成协程切换(g切换到g0)。如果最终获取不到p,就使g加入全局队列,m休眠,开启新一轮调度。

这个留在后面和sysmon一起说

netpoll是go对多路复用的实现。在linux系统上,主要依赖epoll系列系统调用。

在调度过程中,主要是在finerunnable函数中进行netpoll, 返回一组就绪的g

netpoll接口需要实现netpollinit, netpoll, netpollBreak, netpollopen, netpollclose, netpollisPollDescriptor六个函数。与本文主题相关的是前三个

看到这几个函数我不禁留下了感动的眼泪,太熟悉了,就是epoll的套皮。

epollinit先调用epoll_create,再创建epollevent结构体,再创建一个管道进行读方向的监听

netpoll根据传入的参数不同有阻塞、非阻塞、超时阻塞三种模式。调用epoll_wait返回就绪事件,并用netpollready工具函数把这些事件转换成g链表后返回

netpollBreak向管道中写入内容,导致管道的读端事件被触发,这可以中止netpoll的阻塞状态。

所以,现在可以回顾以下findrunnable中所做的关于网络轮询的事情:

1.调用checkTimers运行已经到达的定时器,如果没有定时器到达,就返回一个pollUntil表示下一个定时器的到达时间。如果没有下一个定时器则返回0

2.调用非阻塞的netpoll先尝试获取一波g,这些g可能是之前执行了read或connect等调用而进入阻塞

3.所有能做的事都做完了之后就进行阻塞的netpoll调用。如果pollUntil不为0,则进行带超时的netpoll调用,使得届时能从netpoll中及时返回以响应定时器到达事件

sysmon(system monitor)守护线程有点像linux守护进程或者mysql后台线程,总之都是打杂的。sysmon和其他守护线程一样,也是在一个大循环里进行轮询监控,并对一些异常事件进行处理。

sysmon随着全局g0的启动而启动。

全局g0在main函数中通过newm创建了一个不依赖p的m(第二个参数为nil),并把sysmon函数作为m的底层线程的入口,即创建了sysmon守护线程。

sysmon也会想偷懒,没什么活干就休息了,详见上面的注释

sysmon()函数很长,但功能划分得很清楚,主要就四个部分:检查死锁、网络轮询兜底、对p发起抢占、垃圾收集兜底

核心逻辑是checkdead函数。去除了cgo相关的逻辑,还是蛮简单的

和findrunnable里的逻辑几乎一样。作用是每10ms会进行一次netpoll兜底,将就绪的g注入队列

核心逻辑是retake函数。

preemptone(p)主要是依赖preemptM系统调用,让当前的g停止在p上运行

handoffp(p)通过调用startm函数来启动一个m与p绑定。handoffp秉持着尽量启动m的原则,如果实在太闲了,则不会启动m,而是将p加入空闲列表

如果retake没能抢占任何p,则sysmon的idle自增,表明又白跑一轮。

这不是本文的内容,简单带过一下就是每120秒或触发兜底的ix强制垃圾回收,没什么好说的

最后讲一下定时器的实现,在前面的小节里也出现过很多次了。相比GMP,定时器模块就比较简单了

定时器的数据结构是timer,主要字段如下

pp: 指向定时器所在的p。回忆一下p结构体里也有一个[]*timer保存了所有定时器

when: 下次触发时间

period: 周期定时器(Ticker)的周期

f,arg: 定时器到达的回调函数及其参数

seq: 触发序号

p结构体里的timers其实不是一个简单的切片,而是一个小顶四叉堆(4-ary heap,二叉堆的扩展,也是用数组表示树形结构),堆顶元素就是when最小的,也就是最快到达的定时器。

不信的话看看这段源码:

一行没改!这就是一个经典的堆实现,只不过是四叉的,取父亲下标要除以4。这是向上筛选的函数,向下筛选的函数类似,不展示了

调用addtimer新增定时器,最终进入doaddtimer函数

定时器的触发被放在调度器的调度周期里进行,触发定时器的入口是checkTimers(在schedule()函数内,之前展示过)

如果g的计算量较大,执行时间很长,或者进行频繁大量的系统调用,schedule函数的主动触发频率就会降低,不得不依靠sysmon线程的抢占式调度。在最坏情况下,调度周期可达到10ms,这也就是定时器的最高保证精度。

checkTimers在成功获取到已到达的定时器后,最终会走进runOneTimer

实际上定时器比这更加复杂,它也有复杂的状态机,但是和本文主题相差太远,就在此略过了。