开篇
以下的内容刨除了包括 GC 、mallco、netpoll、timers 等等其他部分的相关逻辑,很多细节略过,并且只关乎调度。
进程启动
go 进程启动,执行的是 main 包中的 main 方法。runtime 包会在 main 方法执行之前做一些初始化的动作,并且会和 GMP 调度联动。
更具体一点来说,go 进程启动刚开始调用的是汇编函数,在汇编函数中会新建 m0 和 g0,并且互相绑定。
- 其中
m0作为初始化的第一个M,会和p绑定之后执行main方法,这个逻辑下面会介绍。 g0作为m执行调度切换时的特殊栈,m执行调度切换或者相关函数之前,需要先切换到g0
之后会做一些系统参数相关的初始化,然后初始化 p 数组, p 的个数和系统的 CPU 核数一致。初始化 p 数组主要会做两件事:
- 把数组中第一个
p和m0绑定 - 把所有的
p放到allp数组中,把空闲的p放到全局调度器的空闲p链表中
m 和 p 都有了,接下来就需要 g 来供 m 和 p 调度执行了。同样是汇编代码中的逻辑,会把 runtime.main 函数作为 groutine 的执行函数传入,然后新建一个 g,
这个 g 会放到 m0 绑定的 p 的可执行队列上,就可以开始调度了。其中 rutime.main 函数中会执行 main.main 方法和各种包的 init 方法,当然还有一些其他关键作用,后面会讲到。
调度
当 m 开始调度, 会执行 runtime.schedule 方法。在调度中除了 GMP 之外还有全局调度器的角色,主要负责一些全局变量的存储和部分组件的缓存,包括多余的可执行 groutine。
g 被生产的大致逻辑为:
- 放到当前
p的可执行队列中 - 满了,则放到全局队列中
其中 g 被调度大致的逻辑为:
m会先从当前p的可执行队列中寻找可执行的g- 没有,则去全局队列中寻找
- 没有,则去其他
p中偷
除此之外,还有一些补充逻辑:
m每隔 61 次会优先从全局调度器的可执行队列中寻找g- 去其他
p中偷的时候,最多偷四次,每次随机一个p开始,偷不到就会释放p并且阻塞m
抢占
上面的顺序调度会存在问题,当一个 groutine 执行时间太长(逻辑太长or陷入系统调用),p 上的其他 groutine 会有饿死的风险。
为了防止这种情况,runtime.main 方法在调用 main.main 方法之前,会启动一个 m 单独执行 runtime.sysmon 方法,在 sysmon 方法中会判断当前执行的 g 是否需要抢占。
判断抢占的逻辑大致如下:
- 循环遍历所有
p,把当前时间和p的调度次数存快照 - 比较
p的调度次数和快照次数,不一样则表示两次循环之间,p发生了调度,不需要抢占 - 否则比较当前时间和快照时间,差值超过
10ms则抢占p的m的当前正在执行的g
上面的抢占逻辑适用正常执行的 g。
如果是陷入系统调用的 g ,判断抢占的逻辑和上面几乎一样,只是在最后一步并不是抢占正在执行的 g,而是把 p 交给其他 m。
信号
找到了执行时间过长的 groutine之后,会发送一个抢占信号给对应的 m,m 收到信号之后会执行相应的信号处理函数,进行抢占处理。
其中信号处理函数是 m0 在启动调度的时候注册的,信号处理函数进程中的所有线程共用。
针对抢占信号的处理逻辑如下:
- 把
g改成待执行 - 切断
g和m的关系 - 把
g放到全局待执行队列中 - 重新调度
并行
上面的调度中,只有一个 p 被 m0 绑定,开始了调度。其他的 p 都放在全局调度器的空闲 p 链表中,并没有被绑定和执行。
其他 p 的绑定和执行在生成 groutine 的方法 runtime.newproc 中。当生成一个 groutine 并且把 groutine 放到当前 p 上之后,
会判断全局调度器的空闲 p 链表中是否有空闲的 p,假如存在就唤醒或者新建一个 m 绑定 p,并且执行调度方法