一、基础知识
在阅读源码前,我们先来熟悉一下相关的基础知识,这是理解runtime的基础。
- 用户级线程:由用户运行时(runtime)来管理线程,操作系统不能感知其存在,所以也不会对其调度,一般会在用户空间提供一个线程库来操作它们。在进程中多个用户级线程对应一个内核级线程,当内核线程阻塞,进程中的所有线程都会被阻塞。Lua的协程(coroutine)、golang的goroutine都属于用户级线程。
- 轻量级进程(LWP):Linux中没有线程概念,跟线程比较相似的是轻量级进程(LWP),一个进程中可以有多个LWT。LWP需要和内核线程绑定才能被执行,与内核线程是一对一的绑定关系。为了方便理解,我们直接把LWP看成是内核线程。
- 混合模型:进程中可以同时有多个内核线程和多个用户线程,用户线程只有绑定内核线程才能被执行,多个用户线程可以进入同一个内核线程的执行队列,由runtime负责用户线程的调度以及用户线程与内核线程的绑定与解绑。golang就是采用此模型。
- OS调度:线程是内核调度的基本单位,在windows和linux上,可以通过CreateThread和clone创建线程(Linux clone创建的是LWP),创建线程需要传递入口地址(通常是函数),线程从这个入口地址开始执行。因为OS中的线程数量大部分时候是>CPU核心数,OS为了确保每个线程都能得到公平的执行提出了时间片的概念,即每个线程只能持续执行一段时间,时间片到了后,保存上下文,切换到其它线程。上下文包含了2个重要的信息分别是PC和堆栈,记录它们可以确保线程被中断后,下次无论在哪个核上都能接着上次中断的现场继续执行。
- CPU:CPU是真正的执行单位,线程是被CPU执行。在内核的调度下,符合条件的线程被CPU执行,正在被执行的其实是线程的局部指令,在这些局部指令可能是线程的某个函数片段或者协程。
二、GPM数据结构
golang runtime
是基于GPM
模型来实现高并发的,它能充分发挥多核CPU的优势, 让每个核的负载更均衡。GMP
在源码里对应了各自的结构体,这里只列出几个理解runtime
必要的字段。
1.1 G(goroutine)
runtime
调度的基本单位。创建goroutine
时会在函数前加go关键字,这个函数地址表示当前goroutine
的入口地址。goroutine
在执行过程中可能因为各种原因被暂停,这时需要保存PC和堆栈信息,以便恢复后时继续执行。
1 | //源码文件 runtime/runtime2.go, runtime/proc.go, runtime/signal_unix.go, runtime/preempt.go |
- 状态:每个
goroutine
都包含如下状态,当goroutine被创建后就在下面这些状态间切换。
1 | _Gidle // 刚被创建,还没初始化 |
1.2 P(Processor)
逻辑处理器,从功能角度来看它更像是个资源管理器,主要包含了goroutine
队列以及当前P的内存分配信息,创建goroutine
时,首先偿试放入当前P的Local队列,如果队列已满,则把本地队列中的一半g转移到全局队列中。P必须和M绑定才能工作,一个P任意时刻只能绑定一个M,所以在P上分配内存,取队列中的goroutine
都不需要加锁。
1 | type p struct { |
P的状态
1 | _Pidle //当M没有G可执行时,P进入空闲列表 |
1.3 M(Machine)
系统线程抽象。M会从P的队列中取G来执行,当G被暂停M会把上下文信息写回G,并取下一个G继续执行。
1 | type m struct { |
三、调度流程
1.1 相关概念
M0
:进程的主线程,启动进程时默认启动M0。G0
:每个M创建的第一个g叫g0,创建M时为g0
分配固定栈空间不走普通g0
的栈扩张流程,g0
不受GC
影响,主要工作包括:goroutine
的创建、调度等。(newproc、schedue、netpoll运行在g0上)- 自旋转:进入调度流程后
findrunable
函数会一直寻找可执行的g(本地队列,全局队列,netpoll
),如果处于自旋转状态,还会偿试去其它p的队列中偷g,直到找到为止。当自旋转中的m数量 < runing中p数量的1/2时,才会有m进入自旋转,这样保证了新到的g能够快速被执行,也确保了runtime
中不会同时存在大量不必要自旋转的M消耗cpu
。
1.2 主要流程
- 当启动
m0
时,会创建GOMAXPROCS
个p, 状态置为_Pidle
。 - 用
go
关键字创建协程时,首先偿试把g放入P本地队列,如果本地队列已满,则把本地队列中的g迁移一半到全局队列。并检查如果存在idle
的P且没有M处于自旋转,则获取一个idle
的P跟idle
的M绑定,如果没有idle
的M,则新建一个M,并让M进入自旋转。g运行结束后g0
会调用schedue
继续寻找可执行的g。 - schedue调用findrunable函数寻找可执行的g,首先从p的本地队列(runq)中寻找可执行(状态为_Grunnable)g;如果没找到则从全局队列中寻找,若全局队列中有则拿一批(
sched.runqsize/gomaxprocs + 1
个,最多拿一半)到本地队列;如果全局队列也没有,则查看netpoll中是否有阻塞的g,如果有则检查epoll是否有fd就绪,有则寻找成功,把这些g的状态从_Grunnable改为_Grunning,并把多余的g放入全局队列;如果netpoll中也没有,则从其它p中偷。 - g的调度没有时间片概念,
sysmon
或gc
抢占g做法是把stackguard0
置stackPreempt
,当g调用函数触发newstack
时根据stackPreempt
标记抢占。在golang1.14
以前如果g中没有函数调用,比如是一个纯数值计算的for
循环,那g永远也不会被抢占,当然gc
也无法成功执行。
四、Runtime源码
1.1 main函数启动
1 | //1. osinit 初始化cpu数量和页大小 |
1.2 创建协程
在函数前加go关键字创建一个协程,其实是调用newproc
函数,fn就是go关键字后面函数地址
1 | func newproc(siz int32, fn *funcval) { |
1.3 调度流程
1 | //需要注意的是:schedule函数及子函数中调用的getg()返回的都是g0,因为schedule是运行在g0上的 |
1.4 sysmon
sysmon(system monitor简称)监控runtime
,它的任务包括触发强制gc、netpool
、暂停运行时间过长的goroutine
。如果M陷入长时间的系统调用、获取锁,则会把当前P与M解绑,若P队列有G则会唤醒一个idle的M或创建一个新的M与之绑定;若P队列没有G,则把M放入idle队列。
1 | func sysmon() { |
五、基于信号抢占调度
golang1.14
以前采用的是基于协作式抢占调度,抢占g时把stackguard0
设置为stackPreempt
。在goroutine
中调用函数时触发newstack
并检测stackPreempt
标记以实现抢占。假如g1是一个长时间纯数值计算的协程或无函数调用的for
循环,在这期间,g1不会被抢占。如果此时触发了gc, 整个进程除了g1以外,其它goroutine
都被gc停止, 直到g1执行完成。golang1.14
加入了基于信号抢占调度,通过向g所在的M发送信号来实现抢占,这可以避免g中无函数调用时不能抢占成功的情况,这对于gc来说尤其重要。
1 | func preemptone(_p_ *p) bool { |
参考
https://mp.weixin.qq.com/s/gTb9p0WpJ37M5_k9e6xUiQ
https://wudaijun.com/2018/01/go-scheduler/
https://zboya.github.io/post/go_scheduler/
https://draveness.me/golang/docs/part3-runtime/ch06-concurrency/golang-goroutine/
https://changkun.de/golang/zh-cn/part2runtime/ch06sched/