这位 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</