Go调度系列--调度器实现原理(二)(go 调度器)
前言
Go 语言在并发编程方面有强大的能力,这离不开语言层面对并发编程的支持,Go调度的本质就是将 Goroutine (G)按照一定算法放到CPU上去执行。在上一篇我们已经知道了GMP各自代表的含义,三者之间的关系,今天从调度的角度去看Go是如何将三者之间进行协作的。
进程、线程、协程
讲Go的调度之前,我们对进程、线程、协程这些概念做个简单了解。
- 进程是系统分配系统资源基本单位
- 线程是CPU调度时的最基本单元
- 协程是一种用户态的轻量级线程,协程的调度完全由用户控制
多个线程可以属于同一个进程并共享内存空间。因为多线程不需要创建新的虚拟内存空间,所以它们也不需要内存管理单元处理上下文的切换,线程之间的通信也正是基于共享的内存进行的,与重量级的进程相比,线程显得比较轻量。
正如一个进程可以拥有多个线程一样,一个线程可以拥有多个协程。Go 语言的调度器通过使用与 CPU 数量相等的线程减少线程频繁切换的内存开销,同时在每一个线程上执行额外开销更低的 Goroutine。
编辑
添加图片注释,不超过 140 字(可选)
Go调度器原理
调度模型演化
Go调度其实本质就是将 Goroutine (G)按照一定算法放到CPU上去执行。因为线程是CPU调度的基本单位,而不是协程,所以Go调度器需要将Goroutine放到内核线程上去(M),然后操作系统调度器将内核线程放到CPU上去执行(这块其实是操作系统层的工作了)。
Go调度器也是经历了多次演化才有现在的版本:
编辑切换为居中
GMP模型演化
设计思想
- 线程复用(work stealing机制和hand off机制)
- 利用并行(利用多核CPU)
- 抢占调度(解决公平性问题) – 基于信号的抢占,避免极端情况下造成的饥饿问题
编辑切换为居中
GMP设计思想
被调度对象
被调度对象其实就是GMP,它们的来源如下:
G | M | P |
---|---|---|
P的runnext(只有一个G,局部性原理,永远会被最先调度执行) | 休眠线程队列(未绑定P,长时间休眠会等待GC回收销毁) | 全局P队列(可手动设置,最多GOMAXPROCSG个P) |
P的本地队列,数组,最多256个Goroutine | 运行线程(绑定P,指向P中的G) | |
全局G队列,链表,无限制 | 自旋线程(绑定P,指向M的G0) | |
网络轮询器network poller(存放网络调用被阻塞的G) |
调度器启动
从编译的角度看调度器启动过程有以下几步:
// The bootstrap sequence is:
// call osinit
// call schedinit
// make &
queue new G
// call runtime·mstart
TEXT runtime·rt0_go(SB),NOSPLIT|TOPFRAME,$0
...
CALL runtime·osinit(SB)
CALL runtime·schedinit(SB)
// create a new goroutine to start program
MOVQ $runtime·mainPC(SB), AX // entry
PUSHQ AX
CALL runtime·newproc(SB)
POPQ AX
// start this M
CALL runtime·mstart(SB)
...
1. 调用 runtime·osinit 来获取系统的cpu个数。
2. 调用 runtime·schedinit 来初始化调度系统,会进行p的初始化,也会把m0和某个p绑定。
3. 调用 runtime·newproc 新建一个goroutine,也叫main goroutine,它的任务函数是 runtime.main 函数,建好后插入到m0绑定的p的本地队列。
4. 调用 runtime·mstart 来启动m,进入启动调度系统。
调度策略
调度策略也叫做调度循环,进入调度系统后调用 mstart1 --> schedule()函数(都在src/runtime/proc.go) ,实际的调度逻辑就在schedule()函数中,它就是不断的获取G,然后执行G,而P充当了中间层,维护了P的本地队列,让M尽量能执行到G,源码如下:
// One round of scheduler: find a runnable goroutine and execute it
// 一个环形调度器:找到一个可运行的goroutine并执行它。
func schedule() {
_g_ := getg()
...
//gp是一个g的结构
var gp *g
var inheritTime bool
...
if gp == nil {
// 每执行61次调度循环会看一下全局队列。为了保证公平,避免全局队列一直无法得到执行的情况,当全局运行队列中有待执行的G时,通过schedtick保证有一定几率会从全局的运行队列中查找对应的Goroutine;
if _g_.m.p.ptr().schedtick%61 == 0 &
&
sched.runqsize >
0 {
lock(&
sched.lock)
gp = globrunqget(_g_.m.p.ptr(), 1)
unlock(&
sched.lock)
}
}
if gp == nil {
// 先尝试从P的runnext和本地队列查找G
gp, inheritTime = runqget(_g_.m.p.ptr())
}
if gp == nil {
// 仍找不到,去全局队列中查找。还找不到,要去网络轮询器中查找是否有G等待运行;仍找不到,则尝试从其他P中窃取G来执行。
gp, inheritTime = findrunnable() // blocks until work is available
// 这个函数是阻塞的,执行到这里一定会获取到一个可执行的G
}
...
// 调用execute,继续调度循环
execute(gp, inheritTime)
}
从上面GMP的来源可以知道,由于 P 中的 G 分布可能在 runnext、本地队列、全局队列、网络轮询器中,则需要挨个判断是否有可执行的 G,大体逻辑如下:
- 每执行61次调度循环,从全局队列获取G
- 若有则直接返回从P 上的 runnext 看一下是否有 G
- 若有则直接返回 从P 上的 本地队列 看一下是否有 G
- 若有则直接返回 上面都没查找到时,则去全局队列、网络轮询器查找或者从其他 P 中窃取,一直阻塞直到获取到一个可用的 G 为止
而从全局队列队列获取G也有规则,实现代码如下:
func globrunqget(_p_ *p, max int32) *g {
...
// gomaxprocs = p的数量
// sched.runqsize是全局队列长度
// 这里n = 全局队列的G平分到每个P本地队列上的数量 + 1
n := sched.runqsize/gomaxprocs + 1
if n >
sched.runqsize {
n = sched.runqsize
}
if max >
0 &
&
n >
max {
n = max
}
// 平分后的数量n不能超过本地队列长度的一半,也就是128
if n >
int32(len(_p_.runq))/2 {
n = int32(len(_p_.runq)) / 2
}
// 执行将G从全局队列中取n个分到当前P本地队列的操作
sched.runqsize -= n
gp := sched.runq.pop()
n--
for ;
n >
0;
n-- {
gp1 := sched.runq.pop()
runqput(_p_, gp1, false)
}
return gp
}
所有 P 平分全局队列中的 G,每个 P 要分得多少个,这里假设会分得 n 个。然后把这 n 个 G,转移到当前 G 所在 P 的本地队列中去。但是最多不能超过 P 本地队列长度的一半(即 128)。而从其它P获取G时,会偷一半的G过来放到当前P的本地队列。
hand off机制
hand off从字面上看是移交,就是把当前线程绑定的P移交。当线程M运行的G进行系统调用阻塞时,线程M释放绑定的P,把P转移给其他空闲的线程进行绑定。
编辑
hand off机制
work stealing机制
当前线程无可用G时,尝试“从其他线程绑定的P,偷取一半P的本地队列G“,work stealing机制就是图中的第四步。
编辑切换为居中
work stealing 机制
触发调度时机
只要调用了runtime.schedule()函数地方我们就可以任务是调度触发的地方,有以下时间点也调用了schedule()。
编辑切换为居中
添加图片注释,不超过 140 字(可选)
除了上图中可能触发调度的时间点,运行时还会在线程启动 runtime.mstart 和 Goroutine 执行结束 runtime.goexit 触发调度,也有以下几个调度路径。
•主动挂起 — runtime.gopark -> runtime.park_m
•系统调用 — runtime.exitsyscall -> runtime.exitsyscall0
•协作式调度 — runtime.Gosched -> runtime.gosched_m -> runtime.goschedImpl
•系统监控 — runtime.sysmon -> runtime.retake -> runtime.preemptone
总结
Go调度器实现原理,多看看也能理解个7788。
参考资料:
【调度器(详细介绍)】
【Go语言设计与实现】
声明:本文内容由网友自发贡献,本站不承担相应法律责任。对本内容有异议或投诉,请联系2913721942#qq.com核实处理,我们将尽快回复您,谢谢合作!
若转载请注明出处: Go调度系列--调度器实现原理(二)(go 调度器)
本文地址: https://pptw.com/jishu/309.html