这位 Gopher 你好呀!如果觉得我的文章还不错,欢迎一键三连支持一下!文章会定期更新,同时可以微信搜索【凑个整数1024】第一时间收到文章更新提醒⏰
背过面试“八股文”的 Gopher 们肯定了解 Go 语言运行时(Go runtime)的 GMP 模型。Go runtime 时通过 GMP 模型可以完成对 goroutine 的创建、调度、销毁等声明周期的管理,实现 Go 语言优秀的高并发性能。本文将重点关注 GMP 模型的的调度策略,聚焦 Go runtime 的两个问题:
- Go runtime 什么时候会触发调度?有哪几种情况?
- Go runtime 调度 goroutine 的算法是什么?
GMP 模型
进入正篇内容前先对前置知识——GMP 模型进行一个大致回顾。由于 GMP 模型本身并不是本文重点,这里只进行简单的概念梳理。
G
G 是 goroutine 的抽象,在文中大部分时候 goroutine 就是 G,在 Go 源码中是 g 类型。G 有自己的运行栈、状态、通用寄存器值、要执行任务函数的地址等,G 需要与 P 进行绑定才可以执行。
M
M 是对操作系统线程的抽象,是拿着 G 所提供的执行上下文,真正去执行的类型,在 Go 源码中是 m 类型。M 同样需要与 P 绑定,由 P 来作为代理去执行 G,因此 M 不需要与 G 进行强绑定,无需保存 G 状态信息,这样 G 也可以实现跨 M 执行。
值得注意的是,每个 M 会保存一个特殊 G 的指针,这个 G 就是 g0:它不用于执行用户函数代码,只用于执行调度策略,是本文内容中的一个重要角色。
P
P 可以理解为线程本地的调度器,或处理器资源池,在 Go 源码中是 p 类型。P 是 GMP 模型的中枢,对 M 与 G 进行动态有机结合。P 中管理着若干 goroutine 调度相关的资源与上下文,如线程本地 G 队列、本地内存池(mcache)、对象缓存等。
对于 G 而言,P 就可以看作是处理器,只有被 P 调度,G 才能够被执行;对于 M 而言,P 就是其执行 G 的代理,无需 M 自己去关心繁杂调度细节(找到可执行 G、内存资源分配等)。
调度策略
Goroutine 的调度无非关注的就是两点:什么时候需要调度,以及如何找到下一个需要执行的 goroutine,这也正好分别对应了文章开头的两个问题。
稍微往底层看下,调度就是一个普通的 G 由于一定的原因需要与当前 M 解绑,而 M 又需要有下一个可以执行的 G。实现这样的调度,就需要用户 G 与 g0 之间进行“反复横跳”。这里我们给出一张宏观的调度图:
执行用户代码的普通 G 调用 mcall()
切换到 g0,g0 通过 schedule()
调度的核心方法,找到下一个可执行的 G,更新新旧 G 的状态,将新 G 与 M 进行绑定,最终调用 gogo()
方法从 g0 切换到新的 G 去执行。
函数mcall()
与 gogo()
就是实现用户 G 与 g0 之间“反复横跳”的关键,是对偶的存在关系。
调度时机与类型
本小节我们来回答第一个问题:什么时候会触发调度?有哪几种情况?
Goroutine 触发调度的情况可以分为以下几种类型:
-
G 正常结束:这种是最简单的情况,G 在一次调度中就完成了执行任务(也有可能手动执行了
runtime.GoExit()
),切换到 g0 去执行goexit0_m()
函数将当前 G 置为死亡(_Gdead
)状态,发起新一轮调度; -
主动调度:一种用户 G 主动让渡的方式,当前 G 主动让出 M,这是 goroutine “协程” 概念的体现(严格来说 Goroutine 不是协程,而是绿色线程,协程主打协作,只有主动让渡,而 Goroutine 会进行)。其主要方式是,用户在执行代码中调用了
runtime.Gosched
方法,此时当前 G 会当让出执行权,主动进行队列等待下次被调度执行。具体代码可以参考runtime/proc.go:GoSched()
,在这个函数中会切换到 g0 去执行gosched_m()
函数;
func Gosched() {
checkTimeouts()
mcall(gosched_m)
}
- 被动调度:当 G 因为不满足某种执行条件,G 会陷入阻塞状态(准确说是
_Gwaiting
),常见的如等待获取互斥锁sync.Mutex
、等待 channel 的读写等,此时只有关注的条件达成后 G 才可以被唤醒,重新进入等待执行队列中(变为_Grunnable
)。如前面说的阻塞情况,使 G 进入阻塞底层都需要执行gopark()
函数,在这个函数中会切换到 g0 去执行park_m()
函数。与gopark()
函数成对偶关系的goready()
函数可以将阻塞的 G 恢复为待执行状态;
func gopark(unlockf func(*g, unsafe.Pointer) bool, lock unsafe.Pointer, reason waitReason, traceReason traceBlockReason, traceskip int) {
mp := acquirem()
gp := mp.curg
// ...
mcall(park_m)
}
func goready(gp *g, traceskip int) {
systemstack(func() {
ready(gp, traceskip, true)
})
}
- 抢占调度:这是一种比较特殊的调度方式,因为其机制与其它三者差异较大,因此在上图中也没有体现。前 3 种调度都由当前 M 的 g0 完成,唯独抢占调度不同。因为发起系统调用时需要打破用户态的边界进入内核态,此时 M 也会因系统调用而陷入阻塞,无法主动完成调度的行为。因此,在 Go 进程中会有一个全局监控协程 monitor G 的存在,这个 G 会越过 P 直接与一个 M 进行绑定,不断轮询对所有 P 的执行状况进行监控。倘若发现满足抢占调度的条件,则会以第三方的角色出手干预。
找到合适的 G
本小节来回答文章开头的第二个问题:调度 goroutine 的算法是什么?即以什么样的策略找到下一个可执行的 G?
调度的核心方法是位于 runtime/proc.go
的 schedule 函数,执行该函数的是 g0。schedule()
会通过 findRunnable()
函数封装的调度算法,找到下一个可执行的 G,然后调用 execute()
,最终 execute()
内部会执行 gogo()
函数,执行新的 G。
func schedule() {
// ...
gp, inheritTime, tryWakeP := findRunnable() // blocks until work is available
// ...
execute(gp, inheritTime)
}
下面我们来看一张 findRunnable()
函数的流程图,描述了其内部封装的调度算法:
- 每个 P 中都会有个调度计数器变量
schedtick
,当每执行 61 次调度后,就会从尝试全局 G 队列中获取一个 G 去执行,这是为了防止全局队列中的 G 饥饿。不过看了下源码发现,除了获取一个 g 用于执行外,还会额外将一个 G 从全局队列转移到当前 P 的本地队列,进一步让全局队列中的 G 也得到更充分的执行机会;
func findRunnable() (gp *g, inheritTime, tryWakeP bool) {
// ...
if pp.schedtick%61 == 0 && sched.runqsize > 0 {
lock(&sched.lock)
gp := globrunqget(pp, 1) // 尝试从全局队列获取 G
unlock(&sched.lock)
if gp != nil {
return gp, false, false
}
}
// ...
}
- 尝试从当前 P 本地队列中获取一个可执行的 G,其核心逻辑位于
runqget()
方法中。由于 work-stealing 机制的存在,其它 P 可能会访问本地 G 队列去窃取 G,因此即使本地 G 队列逻辑上是 P 独有的,也需要加锁去访问,所以这一行为并不是无锁的(网上很多文章写的有问题),只不过 work-stealing 发生频率远低于从本地队列获取 G,因此获得锁的成功率很高,不需要阻塞; - 如果 2 失败,则需要去尝试从全局 G 队列中获取一个 G 去执行。全局 G 队列存储于全局变量
sched.runq
中,因此访问需要加锁; - 如果 3 失败,则会去看看网络轮询器中有没有就绪的 G,也就是
netpoll()
一下,看看返回的就绪 G 的队列是否为空。网络轮询器除了等待 I/O 操作的 G 之外,还有计时器(time.Timer
、time.Ticker
)就绪的 G 也会在这里获取到;
func findRunnable() (gp *g, inheritTime, tryWakeP bool) {
// ...
if netpollinited() && netpollAnyWaiters() && sched.lastpoll.Load() != 0 {
// netpoll(0) 是非阻塞的
if list, delta := netpoll(0); !list.empty() {
gp := list.pop()
casgstatus(gp, _Gwaiting, _Grunnable)
return gp, false, false
}
}
// ...
}
- 如果 4 失败,则需要触发 work-stealing 机制了,即尝试从其它 P 的本地队列中窃取一部分 G。根据源码可知,work-stealing 每次之多会尝试遍历全局 P 队列 4 次,每次遍历随机从一个 P 开始,过程中遇到可以 steal 的 P 就返回,窃取 G 的数量为被窃取的 P 本地队列中一半的 G 数量。
func stealWork(now int64) (gp *g, inheritTime bool, rnow, pollUntil int64, newWork bool) {
pp := getg().m.p.ptr()
ranTimer := false
const stealTries = 4
for i := 0; i < stealTries; i++ {
stealTimersOrRunNextG := i == stealTries-1
for enum := stealOrder.start(fastrand()); !enum.done(); enum.next() {
// ...
}
}
return nil, false, now, pollUntil, ranTime
}
总结
本文主要总结了 Go 语言的宏观调度策略,使用图文结合的方式,对文章开头提出的两个问题:什么时候需要调度,以及如何找到下一个需要执行的 goroutine 作出了回答。