写点什么

Go runtime:带你了解 Go 语言的 GMP 模型与 goroutine 调度

  • 2022 年 9 月 30 日
    上海
  • 本文字数:2536 字

    阅读完需:约 8 分钟

Go runtime:带你了解Go语言的GMP模型与goroutine调度

一、什么是 Goroutine?

goroutine is a lightweight thread managed by the Go runtime.


以上是对 goroutine 的定义,翻译过来就是:goroutine 为 Go runtime 管理的轻量级线程。


轻量级线程,其实也就是协程(Coroutine)。协程与线程最大的区别就在于:

  • 线程是操作系统级别的概念,真实地运行在物理处理器上。

  • 协程是软件层面的概念,由 Go,Java 编程语言自己实现。在 Go 中,goroutine 就由 go runtime 来管理。


goroutine 与线程的区别

内存占用:

  • 线程:Go 创建的 Goroutine 默认栈大小只有 2KB,并且随着程序的运行会进行动态的栈扩容与栈缩容。

  • Goroutine:线程创建时,栈大小大致为 1-8MB(依操作系统而定)。


创建/销毁开销:

  • 线程:线程的创建与销毁是硬件级别的,需要涉及到内核态与用户态的转换。

  • Goroutine:Goroutine 的创建与销毁由 Go runtime 来保证,一切都在用户态完成,消耗更低。


调度开销(上下文切换):

  • 线程:线程的调度涉及到上下文切换,上下文切换需要消耗 CPU 处理器大概 1000-1500 纳秒。

  • Goroutine:Goroutine 的调度由 GMP 模型(下面介绍)决定,在底层不一定涉及到物理层面的上下文切换,消耗更低。


复杂性:

  • 线程:线程的管理复杂,也容易增加编程的复杂度。

  • Goroutine:Goroutine 是轻量级的线程,在 Go 中创建成千上万的 Goroutine 都不是问题。


M:N 模型

说 goroutine 和 GMP,有一个模型需要介绍一下,就是 M:N 调度模型。


Go runtime 会负责 goroutine 的生命周期,Go runtime 在运行的时候会创建 M 个系统线程(OS thread)来运行 N 个 goroutine。


M:N 模型的一句话总结:使用 M 个系统线程来运行 N 个 goroutine。

二、GMP 模型初识

对于 GMP,每一个都是核心概念,我们单独介绍。


P:逻辑处理器

在 Go 程序启动之后,Go runtime 会帮你在程序中启动一批逻辑处理器。


逻辑处理器,按照字面意思,就是虚拟的 CPU Core,并不是真实的物理 CPU,供 Go runtime 来运行程序的。


GOMAXPROCS:

  • 默认情况下,P 的数量与物理 CPU 的数量保持一致,当然,你也可以通过 runtime.GOMAXPROCS 或者 GOMAXPROCS 环境变量来设置 P 的数量。

  • 备注:GOMAXPROCS 最大值为 256。


好了,下面写个程序来带你体验一下:

package main
import ( "fmt" "runtime")
func main() { // NumCPU returns the number of logical // CPUs usable by the current process. fmt.Println(runtime.NumCPU())}
复制代码


上述程序,可以打印出 Go runtime 为你启动的虚拟 CPU Core 的数量。


以我的 MacBook 为例,运行结果如下:

// 环境:macbook
// 查看物理核数sysctl -n machdep.cpu.core_count// 查看逻辑核数sysctl -n machdep.cpu.thread_count// 运行上述Go程序go run main.go
复制代码



可以看到,Go runtime 为我启动了 8 个 P 来运行我的 Go 语言程序。

M:系统线程

在上面我们介绍了 M:N 模型,提到过,Goroutine 最终会运行系统线程(OS thread)上。


没错,M 对应的就是系统线程。是真正意义上创建的线程,要运行在物理 CPU 上的。P 会挑选出 G,然后将 G 与 M 绑定在一起,让 G 得到真正的运行。


它跟我们在 Java,C 中的线程的概念是一样的。

G:Goroutine

Goroutine 就是协程,是应用级别的线程,上面我们介绍了,就不必过多介绍了。


G 会与 M 绑定进行运行,这个操作由 P 完成。


GMP 模型总览

介绍了 GMP 各自的概念,我们先总览一下 GMP 模型(图中有一些概念还没介绍到,先继续看文章):

  • P:虚拟的 CPU Core,下图中包含 2 个。

  • M:系统线程,用来真正运行 Goroutine 的。

  • G:Goroutine。


三、GRQ 与 LRQ

在 GMP 模型中,还有 2 个核心的队列需要介绍一下:

  • 全局队列(Global Run Queue, 简称 GRQ):保存着还没有被分配到 P 的 Goroutine,会有逻辑将 Goroutine 从 GRQ 移动到 LRQ 上。

  • 本地队列(Local Run Queue,简称 LRQ):LRQ 保存着分配到 P 上的 Goroutine,其会被绑定到 M 上进行执行。


你可以回到"二"中我们介绍的 GMP 模型上,其也包含了 global queue 和 local queue。


四、System call

下面我们通过一个例子,带你了解下 GMP 模型的工作流程。


首先,我们假设 G1 运行了一个 system call(系统调用)导致 goroutine 阻塞,该 system call 可能是一个文件读取操作等。


当 G1 发生阻塞时,Go runetime 会将 M1 和 G1 从 P 中解绑,然后从自己的 LRQ 中挑选出待执行的 goroutine 来执行(在下图中是 G2)。


假设 G1 经过了一段时间,阻塞返回,此时 G1 会重新加入到 LRQ 中等待被 P 进行调度。


五、工作窃取

窃取:包含抢夺,争夺,偷取等意思。在 Go runtime 层面,使用工作窃取的机制,保证 goroutine 能够高效的调度,充分地利用处理器的时间。


话不多说,来看一些例子。

从其他 P 中窃取

假设此时系统的状态如下所示:

  • P1 的 LRQ 为空,没有任何需要调度的 G。

  • P2 的 LRQ 包含了一些待调度的 G。


根据窃取机制,P1 会从 P2 的 LRQ 上窃取一些 G 到自己的 LRQ 中,然后进行执行。


例如下图所示,P1 将原先位于 P2 中的 G3 和 G5 窃取到自己的 LRQ 中进行运行。


从 gloabl queue 中窃取

假设此时的程序如下所示,P1 没有其他待调度的 G 了,此时 P2 的 LRQ 也是空的了,会发生什么呢?


此时,P2 会从 GRQ 中窃取 G 到自己的 LRQ 中进行运行,最终如下所示:


小结

可以看到,工作窃取机制,可以使得 Go runtime 永远处于忙碌状态,对工作的调度以及对资源的利用都达到最大化。


六、从上下文切换带你了解 Goroutine 到底快在哪里?

下面从操作系统的线程调度以及 P 的调度对比方面,解释了为什么 GMP 模型比一般的线程调度要快。


使用系统调度器

假设现在有两个线程 T1 和 T2,此时 T1 运行在 C1 处理器上,其发送了一条消息给 T2。


此时,发生上下文切换(ctx switch),此时 T2 线程开始运行,其运行在 C2 处理器上。


此时 T2 处理完成,给 T1 回复消息,此时 T1 又进行了一次上下文切换,获取 CPU 来进行程序处理。


使用 P 调度器

下面来看看 GMP 模型是如何工作的。


首先,与上面一样,有 2 个 goroutine 要完成这些工作。首先 G1 开始运行,其绑定在 M 上。我们说过 M 是系统线程,其是要运行在物理 CPU 上的(如下图所示,M1 运行在 C1 上)。


当 G1 运行完成之后,此时需要 G2 运行,P 让 M1 和 G2 绑定运行。


当 G2 运行之后,再返回到 G1,G1 开始运行,G1 此时仍然运行在 M1 上。


从上面整个流程我们可以看到,在 GMP 层面虽然产生了多次的上下文切换,但是在物理层面使用的仍然是同一个系统线程 M1 和同一个 CPU C1。


从这些实现细节中,可以看到 GMP 模型是比系统调度消耗的资源和时间要少的。


七、小结

本文带大家入门了 GMP 模型的相关核心知识点,当然,GMP 的内容远不止这些,大家可以再继续扩展阅读。

发布于: 刚刚阅读数: 4
用户头像

写一些平时开发看得见摸得着的文章 2021.07.29 加入

哔哩哔哩后端开发工程师。公粽号:董哥的黑板报

评论

发布
暂无评论
Go runtime:带你了解Go语言的GMP模型与goroutine调度_Go_董哥的黑板报_InfoQ写作社区