来源|腾讯技术工程

Go 从 2009 年正式发布以来,依靠其极高运行速度和高效的开发效率,迅速占据市场份额。它为什么能这么“快”呢?近日,腾讯 CSIG 后台开发工程师 joellwang 介绍了 Go 程序为了实现极高的并发性能,其内部调度器的实现架构(G-P-M 模型),以及为了最大限度利用计算资源,Go 调度器是如何处理线程阻塞的场景。以下为重点内容。

Golang 从语言级别支持并发,通过轻量级协程 Goroutine 来实现程序并发运行。

Goroutine 非常轻量,主要体现在以下两个方面:

  • 上下文切换代价小: Goroutine 上下文切换只涉及到三个寄存器(PC / SP / DX)的值修改。而对比线程的上下文切换则需要涉及模式切换(从用户态切换到内核态)、以及 16 个寄存器、PC、SP 等寄存器的刷新。
  • 内存占用少:线程栈空间通常是 2M,Goroutine 栈空间最小 2K。Golang 程序中可以轻松支持 10W 级别的 Goroutine 运行,而线程数量达到 1K 时,内存占用就已经达到 2G。

Go 调度器实现机制

Go 程序通过调度器来调度 Goroutine 在内核线程上执行,但是 Goroutine 并不直接绑定 OS 线程 M 运行,而是由 Goroutine Scheduler 中的 P 来作获取内核线程资源的“中介”。

Go 调度器模型通常被称为 G-P-M 模型,它包括 4 个重要结构,分别是 G、P、M、Sched。

G(Goroutine):每个 Goroutine 对应一个 G 结构体,G 存储 Goroutine 的运行堆栈、状态以及任务函数,可重用。G 并非执行体,每个 G 需要绑定到 P 才能被调度执行。

P(Processor):表示逻辑处理器。P 的数量决定了系统内最大可并行的 G 的数量,前提是物理 CPU 核数≥P 的数量。

M(Machine): OS 内核线程抽象,代表真正执行计算的资源,在绑定有效的 P 后,进入 schedule 循环。而 schedule 循环的机制大致是从 Global 队列、P 的本地队列以及 wait 队列中获取。M 的数量是不定的,由 Go Runtime 调整,M 并不保留 G 状态,这是 G 可以跨 M 调度的基础。

Sched: Go 调度器。它维护存储 M 和 G 的队列以及调度器的一些状态信息。调度器循环的机制大致是从各种队列、P 的本地队列中获取 G,切换到 G 的执行栈上并执行 G 的函数,调用 Goexit 做清理工作并回到 M,如此反复。

在 Go 程序中,通过下面的图示来展示 G-P-M 模型:

P 代表可以“并行”运行的逻辑处理器,每个 P 都被分配到一个系统线程 M,G 代表 Go 协程。

Go 调度器中有两个不同的运行队列:全局运行队列 (GRQ) 和本地运行队列 (LRQ)。

每个 P 都有一个 LRQ,用于管理分配在 P 的上下文中执行的 Goroutines,这些 Goroutine 轮流被与 P 绑定的 M 进行上下文切换。GRQ 适用于尚未分配给 P 的 Goroutines。

G 的数量可以远远大于 M 的数量,换句话说,Go 程序可以利用少量的内核级线程来支撑大量 Goroutine 的并发。多个 Goroutine 通过用户级别的上下文切换来共享内核线程 M 的计算资源,但对于操作系统来说并没有线程上下文切换产生的性能损耗。

为了更加充分地利用线程的计算资源,Go 调度器采取了以下几种调度策略

1. 任务窃取(work-stealing)

有的 Goroutine 运行快,有 Goroutine 运行慢。当每个 P 之间的 G 任务不均衡时,调度器允许从 GRQ,或者其他 P 的 LRQ 中获取 G 执行。

2. 减少阻塞

如果正在执行的 Goroutine 阻塞了线程 M 怎么办?P 上 LRQ 中的 Goroutine 会获取不到调度吗?

在 Go 中,阻塞主要分为以下 4 种场景。

场景 1:由于原子、互斥量或通道操作调用导致 Goroutine 阻塞。调度器将把当前阻塞的 Goroutine 切换出去,重新调度 LRQ 上的其他 Goroutine。

场景 2:由于网络请求和 IO 操作导致 Goroutine 阻塞。此时,Go 程序会提供网络轮询器(NetPoller)来处理网络请求和 IO 操作的问题,其后台通过 kqueue(MacOS),epoll(Linux)或 iocp(Windows)来实现 IO 多路复用。

通过使用网络轮询器进行网络系统调用,调度器可以防止 Goroutine 在进行这些系统调用时阻塞 M。这可以让 M 执行 P 的 LRQ 中其他的 Goroutines,而不需要创建新的 M。有助于减少操作系统上的调度负载。

场景 3:在调用一些系统方法的过程中发生阻塞。这种情况下,网络轮询器无法使用,而进行系统调用的 Goroutine 将阻塞当前 M。此时,调度器将阻塞的 M 与 P 分离,同时创建新 M 来服务 P。

场景 4:如果在 Goroutine 中执行一个 sleep 操作,导致 M 被阻塞了。 Go 程序后台有一个监控线程 sysmon,它监控那些长时间运行的 G 任务然后设置可以强占的标识符,别的 Goroutine 就可以抢先进来执行。

只要下次这个 Goroutine 进行函数调用,那么就会被强占,同时也会保护现场,然后重新放入 P 的本地队列里面等待下次执行。