go语言为什么能变的如此受欢迎,很大一个原因是其原生支持高并发,而这核心来自goroutine的设计,而对goroutine的管理则离不开go的调度器。
调度的定义
要理解调度,就必要先理解操作系统、进程、线程等调度的基本概念。
- 操作系统:一种计算机程序,管理硬件与软件资源,计算机系统的"底层软件",如windows,macOS,Linux
- 进程:操作系统下程序运行单位,有独立的内存,由内核调度
- 线程:操作系统调度最小单元,一般属于进程,共享进程的内存与资源,由内核调度(比进程轻量)
- 协程:用户级线程,不由内核调度,通过程序员显示的调度(比线程轻量)
- 调度:调度本质上就是一个资源分配算法。
Go 调度设计
goroutine 和线程的关系即一个main函数创建一个操作线程——主线程,之后会由go的调度模型来负责创建和管理 goroutine 分配到具体哪个线程.
历史
- 最简单的调度器(Go 0.x):单线程 G-M:缺陷是程序只能存在一个活跃线程
- 改进为多线程(Go 1.0):G-M:互斥锁带来的开销
- 进阶版本(Go 1.2 至今):G-P-M:多了一层P抽象
G-P-M
-
定义:src/runtime/runtime2.go
-
G:goroutine,需要绑定一个 m 来运行
type g struct { ... m *m // current m; offset known to arm liblink preempt bool // 抢占标记 stackguard0 uintptr // ... }
-
P:processor, a resource that is required to execute Go code.中间层,G绑定到P才能被M调度
type p struct { ... runq [256]guintptr // 环形队列 ... }
-
M:worker thread, or machine.操作系统抽象线程,负责调度,从P获取G执行
type m struct { g0 *g // goroutine with scheduling stack ... curg *g // current running goroutine p puintptr // attached p for executing go code (nil if not executing go code) nextp puintptr oldp puintptr // the p that was attached before executing a syscall ... }
-
-
调度模型
- 调度流程:src/runtime/proc.go
- 宏观白话文:M和P绑定,不断地从P的队列里面取出G运行,当P的队列没有G了,工作狂M还会从global队列获取G(转移一部分到P的队列)来执行,如果global也没有,M还会从其他的P窃取。
- show me the code: 核心代码就是如何获取g,findRunnable
// 找到一个可运行的 goroutine 来执行。
// 尝试从其他 P 窃取,从本地或全局队列中获取 g,轮询网络。
// tryWakeP 表示返回的goroutine不正常(GC worker, trace reader) 所以调用者应该尝试唤醒一个 P。
func findRunnable() (gp *g, inheritTime, tryWakeP bool) {
_g_ := getg()
top:
_p_ := _g_.m.p.ptr()
... // 忽略 gc 相关
// local runq
if gp, inheritTime := runqget(_p_); gp != nil {
return gp, inheritTime, false
}
// global runq
if sched.runqsize != 0 {
lock(&sched.lock)
gp := globrunqget(_p_, 0)
unlock(&sched.lock)
if gp != nil {
return gp, false, false
}
}
... // 忽略网络轮询器相关
// Spinning Ms:窃取其他 Ps 的工作。
// 限制旋转 Ms 的数量为忙碌 Ps 数量的一半。
// 这是防止 CPU 消耗过多的必要条件
// GOMAXPROCS>>1 但程序并行度低。
procs := uint32(gomaxprocs)
if _g_.m.spinning || 2*atomic.Load(&sched.nmspinning) < procs-atomic.Load(&sched.npidle) {
if !_g_.m.spinning {
_g_.m.spinning = true
atomic.Xadd(&sched.nmspinning, 1)
}
gp, inheritTime, tnow, w, newWork := stealWork(now)
now = tnow
if gp != nil {
// Successfully stole.
return gp, inheritTime, false
}
if newWork {
// There may be new timer or GC work; restart to
// discover.
goto top
}
if w != 0 && (pollUntil == 0 || w < pollUntil) {
// Earlier timer to wait for.
pollUntil = w
}
}
... // 实在无事可做,drop P
}
阻塞与抢占
阻塞是指当前的G因为一些操作,如系统调用,需要等待,此时M未被利用情况
抢占是指一个G在没有阻塞情况下执行占用M的时间过长,其他G得不到执行,其他G需要抢占M来获得执行
抢占式调度
操作系统的调度是时间片,而go没有该设计,go是通过sysmon的监控程序来实现的。
-
sysmon: src/runtime/proc.go:5134
// 不需要绑定P运行 func sysmon() { ... for { // 20us - 10ms 唤醒一次 usleep(delay) ... // 重新获取在系统调用中阻塞的 P // 并抢占长时间运行的 G if retake(now) != 0 { idle = 0 } else { idle++ } ... } } func retake(now int64) uint32 { ... s := _p_.status sysretake := false if s == _Prunning || s == _Psyscall { // 如果P运行时间过长则抢占 t := int64(_p_.schedtick) if int64(pd.schedtick) != t { pd.schedtick = uint32(t) pd.schedwhen = now } else if pd.schedwhen+forcePreemptNS <= now { preemptone(_p_) // 在 goroutine 上标记 gp.preempt = true // In case of syscall, preemptone() doesn't // work, because there is no M wired to P. sysretake = true } } ... }
阻塞情况下的调度
-
syscall
如果G被阻塞在某个system call操作上,那么不光G会阻塞,执行该G的M也会解绑P(实质是被sysmon抢走了),与G一起进入sleep状态。如果此时有idle的M,则P与该M绑定继续执行其他G;如果没有idle M,但仍然有其他G要去执行,那么就会创建一个新M。当阻塞在syscall上的G完成syscall调用后,G会去尝试获取一个可用的P,如果没有可用的P,那么G会被标记为runnable,之前的那个sleep的M将再次进入sleep。
-
channel 或 network I/O
如果G被阻塞在某个channel操作或network I/O操作上时,G会被放置到某个wait队列中,而M会尝试运行下一个runnable的G;如果此时没有runnable的G供m运行,那么m将解绑P,并进入sleep状态。当I/O available或channel操作完成,在wait队列中的G会被唤醒,标记为runnable,放入到某P的队列中,绑定一个M继续执行。
其他的调度方式
- NUMA调度提议
###调度器在Go中如何运行
要理解调度器,仅仅知道G-P-M还不够,还需要了解完整的调度流程,调度器的初始化、goroutine的添加等等,这一块会再去读一读源码,理解一下。
在工作中的使用
工作中总是充满了CURD,我常常迷惑于了解了这些知识有什么作用,我又该如何应用。
-
了解调度,我们才能知道该如何在应用中去使用 goroutine,goroutine 虽然轻量,但也不是越多越好。
-
遇到一些问题时,我们可以通过查看调度来思考代码的优化方案。
-
如何查看go调度器:通过GODEBUG运行时环境变量的schedtrace=1000参数,可以每隔1000ms查看一次调度器状态。
GODEBUG=schedtrace=1000 ./main 打印 SCHED 1007ms: gomaxprocs=4 idleprocs=4 threads=9 spinningthreads=0 idlethreads=5 runqueue=0 [0 0 0 0] SCHED 2011ms: gomaxprocs=4 idleprocs=4 threads=9 spinningthreads=0 idlethreads=5 runqueue=0 [0 0 0 0] 参数含义 gomaxprocs: P的数量; idleprocs: 处于idle状态的P的数量;通过gomaxprocs和idleprocs的差值,我们就可知道执行go代码的P的数量; threads: os threads的数量,包含scheduler使用的m数量,加上runtime自用的类似sysmon这样的thread的数量; spinningthreads: 处于自旋状态的os thread数量; idlethread: 处于idle状态的os thread的数量; runqueue: go scheduler全局队列中G的数量; [0 0 0 0]: 分别为4个P的local queue中的G的数量。
总结
- go 调度经历过几个版本,很巧妙的地方就是中间层P的抽象,很多设计的核心都很简单,但是其最迷人的地方就是抽象。
- 很多文章都讲过G-P-M调度,内容其实大同小异,在写这篇博文的时候,去翻看了源码,其实这些东西都在代码里,且有很好的注释。
相关资料
-
《Go 语言设计与实现》—— 左神