如上文 go 语言学习 中提到,GMP 是 go 语言的一个核心设计。对于 Goroutine 的调度机制,一直非常好奇,最近翻阅了源码,也做了一些小实验,算是有了个基本的理解,解释了心中的很多疑惑。
GMP 解释
G:Goroutine,用户态协程,表示一个执行任务,实际上是一个 Goroutine 数据结构 + 一段连续的内存当做 stack
M:Machine,实际是一个系统线程,操作系统调度的最小单元
P:Processor,虚拟处理器,实际上只是 runtime 中的数据结构,用于限制当前正在运行的 Goroutine 数量
几个知识点:
- P 通常跟 CPU 核心数一样多,表示当前这个 go 程序可以占用几个 CPU 核心。
- P 是需要绑定一个实际的 M 才能运行,毕竟系统线程才能真正的在物理 CPU 上执行任务。
- 但是,M 通常会比 P 更多一些,比如碰到阻塞的操作,P 就会和 M 分离,如果有很多阻塞任务,M 就可能会非常多。
为什么需要调度
Goroutine 虽然底层 主要 是协作式的调度,但是调度的细节对于使用者则完全屏蔽了。
从这个角度看,Goroutine 很像操作系统的线程,即使底层有调度切换,但是让使用者认为 Goroutine/系统线程 是一直在运行的。
对于使用者而言,可以不关心,Goroutine 是何时被挂起的,又是何时恢复执行的(当然,为了写出更好的代码,我们肯定还是需要了解这些实现机制的)。
对于上层屏蔽了,就需要语言层面来处理了,也就有 runtime 里的调度机制了。
1 | go 语言暴露的 runtime.Gosched 只是让当前 Goroutine 临时让出执行权,避免大计算任务的长期阻塞,并不是严格的挂起。跟 OpenResty 中 ngx.sleep(0) 的作用一样。 |
对比 Lua
相对于 Lua 这门嵌入式语言而言,则是完全把控制权交给了宿主程序。
具体来说,Lua 对外提供了挂起 yield
和 恢复 resume
的 API,由上层使用者来控制 coroutine 的执行。
当然,虽然需要使用者操心的事情更多,不过在嵌入式场景中,对于宿主程序某些方面反而更友好,可以更精确的控制程序的执行。
比如,并发协程的调度优先级,对于协程中执行的不同类型任务,我们希望可以有不同的优先级。
但是,Goroutine 是完全被平等对待的,上层没有办法控制调度的优先级;Lua 里则不存在这个问题,直接通过 lua_resume
恢复执行某个协程即可。
对比 OpenResty uthread
Goroutine 的使用体验 和 OpenResty 中的 uthread 使用非常类似,同步非阻塞,即:以同步的方式写代码,实际上是则是以异步非阻塞的方式执行。
Goroutine 是由 runtime 来驱动运行,OpenResty uthread 则是由 Nginx 的事件循环来驱动运行。
当然,实际上这两还是有些区别的,Goroutine 抽象在语言层面,对于很多的非网络系统调用,都实现了非阻塞的调度(虽然比较重,底层还是通过堆线程来实现);OpenResty uthread 则只是对网络调用封装了同步非阻塞,对于其他的阻塞式系统调用,还是会阻塞当前 worker 进程的。
调度器的任务
对于协程调度而言,核心就两个任务:
- 挂起
- 恢复
挂起的核心在于选择时间点,Goroutine 有这么几种方式会挂起:
- 协作式的主动让出
- 进入系统调用发生了阻塞,被监控线程强制切走
- Goroutine 执行时间太长,被中断信号切走
恢复逻辑则比较的固定,按照这个顺序查找可以执行的 Gorounine (runable)。
- P.runnext 指向的 G
- 本地队列
- 全局队列
- 检查 netpoll(timeout = 0)
- 从其他 P 去偷 G
- 检查 netpoll (timeout > 0)
对照如下经典的 GMP 模型图,会更好理解一些。
Goroutine 切换发生了什么
首先 Go 编译生成的机器指令,操作 Goroutine 栈的方式,跟 C 语言很像。
比如在 X64 CPU 架构下,通过 rsp
寄存器来指向栈顶,然后通过 rsp
的相对位置来操作栈内存。
所以,在 Goroutine 切换的时候,肯定会发生栈切换。
对比之下,Lua coroutine 的栈,则只是纯粹的一段内存,Lua coroutine 切换,并不需要改变 rsp
的值。
另外,每个 M 都有一个固定的 g0 栈,Goroutine 的切换实际上存在两次切栈操作,比如 g1 切换到 g2 的时候,会发生:
- g1 让出执行权,切换到 g0 栈
- g0 执行 scheduler,找到 g2 开始执行
因为 scheduler 的逻辑还是比较复杂的,不适合在 g1 上执行了。
要在 g1 上执行的话,至少需要在 g1 上预留比较多的空闲栈内存空间,否则就可能会栈溢出了。
当然 g0 还有其他用处,这个以后再说。
实际案例
准备了两个小例子,实际看看 Goroutine 是如何切换的。
- 网络调用
- 调用 C 函数
且听下回分解。