Go 语言在并发编程方面有强大的能力,这离不开语言层面对并发编程的支持,Go调度的本质就是将 Goroutine (G)按照一定算法放到CPU上去执行。在上一篇我们已经知道了GMP各自代表的含义,三者之间的关系,今天从调度的角度去看Go是如何将三者之间进行协作的。
讲Go的调度之前,我们对进程、线程、协程这些概念做个简单了解。
多个线程可以属于同一个进程并共享内存空间。因为多线程不需要创建新的虚拟内存空间,所以它们也不需要内存管理单元处理上下文的切换,线程之间的通信也正是基于共享的内存进行的,与重量级的进程相比,线程显得比较轻量。
正如一个进程可以拥有多个线程一样,一个线程可以拥有多个协程。Go 语言的调度器通过使用与 CPU 数量相等的线程减少线程频繁切换的内存开销,同时在每一个线程上执行额外开销更低的 Goroutine。
编辑
添加图片注释,不超过 140 字(可选)
Go调度其实本质就是将 Goroutine (G)按照一定算法放到CPU上去执行。因为线程是CPU调度的基本单位,而不是协程,所以Go调度器需要将Goroutine放到内核线程上去(M),然后操作系统调度器将内核线程放到CPU上去执行(这块其实是操作系统层的工作了)。
Go调度器也是经历了多次演化才有现在的版本:
编辑切换为居中
GMP模型演化
编辑切换为居中
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,大体逻辑如下:
而从全局队列队列获取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从字面上看是移交,就是把当前线程绑定的P移交。当线程M运行的G进行系统调用阻塞时,线程M释放绑定的P,把P转移给其他空闲的线程进行绑定。
编辑
hand off机制
当前线程无可用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语言设计与实现】
原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。
如有侵权,请联系 cloudcommunity@tencent.com 删除。
原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。
如有侵权,请联系 cloudcommunity@tencent.com 删除。