Go调度器系列(2)宏观看调度器

9次阅读

共计 4959 个字符,预计需要花费 13 分钟才能阅读完成。

上一篇文章《Go 语言高阶:调度器系列(1)起源》,学 goroutine 调度器之前的一些背景知识,这篇文章则是为了对调度器有个宏观的认识,从宏观的 3 个角度,去看待和理解调度器是什么样子的,但仍然不涉及具体的调度原理。
三个角度分别是:

调度器的宏观组成
调度器的生命周期
GMP 的可视化感受

在开始前,先回忆下调度器相关的 3 个缩写:

G: goroutine,每个 G 都代表 1 个 goroutine

M: 工作线程,是 Go 语言定义出来在用户层面描述系统线程的对象,每个 M 代表一个系统线程

P: 处理器,它包含了运行 Go 代码的资源。

3 者的简要关系是 P 拥有 G,M 必须和一个 P 关联才能运行 P 拥有的 G。
调度器的功能
《Go 语言高阶:调度器系列(1)起源》中介绍了协程和线程的关系,协程需要运行在线程之上,线程由 CPU 进行调度。
在 Go 中,线程是运行 goroutine 的实体,调度器的功能是把可运行的 goroutine 分配到工作线程上。
Go 的调度器也是经过了多个版本的开发才是现在这个样子的,

1.0 版本发布了最初的、最简单的调度器,是 G - M 模型,存在 4 类问题
1.1 版本重新设计,修改为 G -P- M 模型,奠定当前调度器基本模样

1.2 版本加入了抢占式调度,防止协程不让出 CPU 导致其他 G 饿死

在 $GOROOT/src/runtime/proc.go 的开头注释中包含了对 Scheduler 的重要注释,介绍 Scheduler 的设计曾拒绝过 3 种方案以及原因,本文不再介绍了,希望你不要忽略为数不多的官方介绍。
Scheduler 的宏观组成
Tony Bai 在《也谈 goroutine 调度器》中的这幅图,展示了 goroutine 调度器和系统调度器的关系,而不是把二者割裂开来,并且从宏观的角度展示了调度器的重要组成。

自顶向下是调度器的 4 个部分:

全局队列(Global Queue):存放等待运行的 G。

P 的本地队列:同全局队列类似,存放的也是等待运行的 G,存的数量有限,不超过 256 个。新建 G ’ 时,G’ 优先加入到 P 的本地队列,如果队列满了,则会把本地队列中一半的 G 移动到全局队列。

P 列表:所有的 P 都在程序启动时创建,并保存在数组中,最多有 GOMAXPROCS 个。

M:线程想运行任务就得获取 P,从 P 的本地队列获取 G,P 队列为空时,M 也会尝试从全局队列拿一批 G 放到 P 的本地队列,或从其他 P 的本地队列偷一半放到自己 P 的本地队列。M 运行 G,G 执行之后,M 会从 P 获取下一个 G,不断重复下去。

Goroutine 调度器和 OS 调度器是通过 M 结合起来的,每个 M 都代表了 1 个内核线程,OS 调度器负责把内核线程分配到 CPU 的核上执行。
调度器的生命周期
接下来我们从另外一个宏观角度——生命周期,认识调度器。
所有的 Go 程序运行都会经过一个完整的调度器生命周期:从创建到结束。

即使下面这段简单的代码:
package main

import “fmt”

// main.main
func main() {
fmt.Println(“Hello scheduler”)
}
也会经历如上图所示的过程:

runtime 创建最初的线程 m0 和 goroutine g0,并把 2 者关联。
调度器初始化:初始化 m0、栈、垃圾回收,以及创建和初始化由 GOMAXPROCS 个 P 构成的 P 列表。
示例代码中的 main 函数是 main.main,runtime 中也有 1 个 main 函数——runtime.main,代码经过编译后,runtime.main 会调用 main.main,程序启动时会为 runtime.main 创建 goroutine,称它为 main goroutine 吧,然后把 main goroutine 加入到 P 的本地队列。
启动 m0,m0 已经绑定了 P,会从 P 的本地队列获取 G,获取到 main goroutine。
G 拥有栈,M 根据 G 中的栈信息和调度信息设置运行环境
M 运行 G
G 退出,再次回到 M 获取可运行的 G,这样重复下去,直到 main.main 退出,runtime.main 执行 Defer 和 Panic 处理,或调用 runtime.exit 退出程序。

调度器的生命周期几乎占满了一个 Go 程序的一生,runtime.main 的 goroutine 执行之前都是为调度器做准备工作,runtime.main 的 goroutine 运行,才是调度器的真正开始,直到 runtime.main 结束而结束。
GMP 的可视化感受
上面的两个宏观角度,都是根据文档、代码整理出来,最后我们从可视化角度感受下调度器,有 2 种方式。
方式 1:go tool trace
trace 记录了运行时的信息,能提供可视化的 Web 页面。
简单测试代码:main 函数创建 trace,trace 会运行在单独的 goroutine 中,然后 main 打印 ”Hello trace” 退出。
func main() {
// 创建 trace 文件
f, err := os.Create(“trace.out”)
if err != nil {
panic(err)
}
defer f.Close()

// 启动 trace goroutine
err = trace.Start(f)
if err != nil {
panic(err)
}
defer trace.Stop()

// main
fmt.Println(“Hello trace”)
}
运行程序和运行 trace:
➜ trace git:(master) ✗ go run trace1.go
Hello trace
➜ trace git:(master) ✗ ls
trace.out trace1.go
➜ trace git:(master) ✗
➜ trace git:(master) ✗ go tool trace trace.out
2019/03/24 20:48:22 Parsing trace…
2019/03/24 20:48:22 Splitting trace…
2019/03/24 20:48:22 Opening browser. Trace viewer is listening on http://127.0.0.1:55984
效果:

从上至下分别是 goroutine(G)、堆、线程(M)、Proc(P)的信息,从左到右是时间线。用鼠标点击颜色块,最下面会列出详细的信息。
我们可以发现:

runtime.main 的 goroutine 是 g1,这个编号应该永远都不变的,runtime.main 是在 g0 之后创建的第一个 goroutine。
g1 中调用了 main.main,创建了 trace goroutine g18。g1 运行在 P2 上,g18 运行在 P0 上。
P1 上实际上也有 goroutine 运行,可以看到短暂的竖线。

go tool trace 的资料并不多,如果感兴趣可阅读:https://making.pusher.com/go-…,中文翻译是:https://mp.weixin.qq.com/s/nf…。
方式 2:Debug trace
示例代码:
// main.main
func main() {
for i := 0; i < 5; i++ {
time.Sleep(time.Second)
fmt.Println(“Hello scheduler”)
}
}
编译和运行,运行过程会打印 trace:
➜ one_routine2 git:(master) ✗ go build .
➜ one_routine2 git:(master) ✗ GODEBUG=schedtrace=1000 ./one_routine2
结果:
SCHED 0ms: gomaxprocs=8 idleprocs=5 threads=5 spinningthreads=1 idlethreads=0 runqueue=0 [0 0 0 0 0 0 0 0]
SCHED 1001ms: gomaxprocs=8 idleprocs=8 threads=5 spinningthreads=0 idlethreads=3 runqueue=0 [0 0 0 0 0 0 0 0]
Hello scheduler
SCHED 2002ms: gomaxprocs=8 idleprocs=8 threads=5 spinningthreads=0 idlethreads=3 runqueue=0 [0 0 0 0 0 0 0 0]
Hello scheduler
SCHED 3004ms: gomaxprocs=8 idleprocs=8 threads=5 spinningthreads=0 idlethreads=3 runqueue=0 [0 0 0 0 0 0 0 0]
Hello scheduler
SCHED 4005ms: gomaxprocs=8 idleprocs=8 threads=5 spinningthreads=0 idlethreads=3 runqueue=0 [0 0 0 0 0 0 0 0]
Hello scheduler
SCHED 5013ms: gomaxprocs=8 idleprocs=8 threads=5 spinningthreads=0 idlethreads=3 runqueue=0 [0 0 0 0 0 0 0 0]
Hello scheduler
看到这密密麻麻的文字就有点担心,不要愁!因为每行字段都是一样的,各字段含义如下:

SCHED:调试信息输出标志字符串,代表本行是 goroutine 调度器的输出;
0ms:即从程序启动到输出这行日志的时间;
gomaxprocs: P 的数量,本例有 8 个 P;
idleprocs: 处于 idle 状态的 P 的数量;通过 gomaxprocs 和 idleprocs 的差值,我们就可知道执行 go 代码的 P 的数量;
threads: os threads/ M 的数量,包含 scheduler 使用的 m 数量,加上 runtime 自用的类似 sysmon 这样的 thread 的数量;
spinningthreads: 处于自旋状态的 os thread 数量;
idlethread: 处于 idle 状态的 os thread 的数量;
runqueue=0:Scheduler 全局队列中 G 的数量;

[0 0 0 0 0 0 0 0]: 分别为 8 个 P 的 local queue 中的 G 的数量。

看第一行,含义是:刚启动时创建了 8 个 P,其中 5 个空闲的 P,共创建 5 个 M,其中 1 个 M 处于自旋,没有 M 处于空闲,8 个 P 的本地队列都没有 G。
再看个复杂版本的,加上 scheddetail= 1 可以打印更详细的 trace 信息。
命令:
➜ one_routine2 git:(master) ✗ GODEBUG=schedtrace=1000,scheddetail=1 ./one_routine2
结果:
截图可能更代码匹配不起来,最初代码是 for 死循环,后面为了减少打印加了限制循环 5 次
每次分别打印了每个 P、M、G 的信息,P 的数量等于 gomaxprocs,M 的数量等于 threads,主要看圈黄的地方:

第 1 处:P1 和 M2 进行了绑定。
第 2 处:M2 和 P1 进行了绑定,但 M2 上没有运行的 G。
第 3 处:代码中使用 fmt 进行打印,会进行系统调用,P1 系统调用的次数很多,说明我们的用例函数基本在 P1 上运行。
第 4 处和第 5 处:M0 上运行了 G1,G1 的状态为 3(系统调用),G 进行系统调用时,M 会和 P 解绑,但 M 会记住之前的 P,所以 M0 仍然记绑定了 P1,而 P1 称未绑定 M。

总结时刻
这篇文章,从 3 个宏观的角度介绍了调度器,也许你依然不知道调度器的原理,心里感觉模模糊糊,没关系,一步一步走,通过这篇文章希望你了解了:

Go 调度器和 OS 调度器的关系
Go 调度器的生命周期 / 总体流程
P 的数量等于 GOMAXPROCS
M 需要通过绑定的 P 获取 G,然后执行 G,不断重复这个过程

示例代码
本文所有示例代码都在 Github,可通过阅读原文访问:golang_step_by_step/tree/master/scheduler
参考资料

Go 程序的“一生”
也谈 goroutine 调度器
Debug trace, 当前调度器设计人 Dmitry Vyukov 的文章
Go tool trace 中文翻译
Dave 关于 GODEBUG 的介绍

最近的感受是:自己懂是一个层次,能写出来需要抬升一个层次,给他人讲懂又需要抬升一个层次。希望朋友们有所收获。

如果这篇文章对你有帮助,请点个赞 / 喜欢,感谢。
本文作者:大彬

如果喜欢本文,随意转载,但请保留此原文链接:http://lessisbetter.site/2019/03/26/golang-scheduler-2-macro-view/

正文完
 0