Go 语言(Golang)的一大显著特性是在其语法层面就内建了对协程,即 goroutine 的支持,并且其运行时(runtime)系统为这一功能提供了强大且原生的支撑。在我看来,选择使用协程而非传统的线程来支持高并发任务,带来了诸多益处:
channel
和 select
等机制,goroutine 使得编写和理解并发逻辑更为简单和安全,减少了对传统并发编程中复杂锁机制的依赖。然而,这些轻量级的 goroutine 终究需要依托实际的操作系统线程才能在 CPU 上执行。Go 语言是如何高效管理这些 goroutine 的呢?这就引出了我们今天要深入探讨的核心机制—— GPM 模型 。
GPM 是 Go 调度器中三个核心组件的缩写:
GOMAXPROCS
决定,默认情况下等于可用的 CPU 核心数。我们首先从宏观层面理解这种设计背后的考量:
通过设定 GOMAXPROCS
来控制 P 的数量,Go 程序既能确保充分利用多核 CPU 的计算能力,又避免了因过多线程竞争 CPU 资源而导致的性能下降。通常,P 的数量与 CPU 核心数相等,这意味着在理想情况下,每个核心都有一个 P 在积极地调度和执行 G。
P 的角色至关重要,它作为 G 和 M 之间的桥梁。P 持有一个本地可运行 G 的队列 (LRQ),当 M 需要执行任务时,它会从其关联的 P 的 LRQ 中获取 G 来执行。这种设计使得 G 的调度大部分发生在用户态,避免了频繁的内核态切换。
此外,P 与 M 的结合实现了线程的复用。当一个 M 因为执行的 G 进行了阻塞性的系统调用(syscall)而被阻塞时,它所关联的 P 可以被释放,并被另一个空闲的 M 或者一个新创建的 M 获取,从而继续执行 P 本地队列中的其他 G。这样就避免了因为少数阻塞操作导致大量线程闲置,同时也减少了线程频繁创建和销毁的开销,相当于 Go runtime 内部实现了一个高效的线程池。这一切对编写 Go 代码的用户来说是透明的。
要理解 GPM 的调度机制,首先需要了解几个关键的概念和数据结构:
g0
:每个 M 都有一个特殊的 goroutine,称为 g0
。g0
拥有自己的栈空间(独立于用户 G 的栈,通常较大),主要用于执行调度相关的代码、垃圾回收的辅助工作以及其他运行时任务。当 M 需要切换到某个用户 G 执行时,会从 g0
栈切换到用户 G 的栈;反之亦然。m.curg
:指向当前在 M 上运行的用户 G。_Gidle
(闲置,刚被分配还未使用)、_Grunnable
(可运行,在运行队列中等待调度)、_Grunning
(运行中,正在 M 上执行)、_Gsyscall
(进行系统调用,M 已与 P 分离)、_Gwaiting
(等待中,如等待 channel 操作、锁、或定时器)、_Gdead
(已结束,资源可回收)、_Gcopystack
(栈复制中,通常在栈增长时发生)、_Gpreempted
(被抢占,等待重新调度)。_Pidle
(闲置,没有 M 与之关联或没有可运行的 G)、_Prunning
(运行中,有 M 与之关联并正在执行 G 或调度代码)、_Psyscall
(其关联的 M 正在进行一个阻塞的系统调用,P 本身可能被其他 M 使用)、_Pgcstop
(因垃圾回收而停止)、_Pdead
(不再使用,例如 GOMAXPROCS
被调小时)。调度决策在很大程度上是每个 M 各自独立在其 g0
栈上执行的。当一个 M 空闲下来(例如,其当前 G 执行完毕或被阻塞),它会运行调度代码来寻找下一个可运行的 G。
sysmon
后台监控线程 :这是一个特殊的 M(不与 P 绑定),它负责一些全局性的协调任务,比如垃圾回收的触发和辅助、网络轮询器(Netpoller)事件的处理(间接影响调度,通过将等待 I/O 的 G 变为可运行状态)、以及检测并抢占长时间运行的 G。sysmon
更像是一个维护者和协调者,而非一个命令下发者。接下来,我们通过几个例子来具体阐述调度过程:
1. 基本调度流程
假设我们有一个 P0 和一个 M0,P0 的 LRQ 中有 G1。
g0
栈上执行调度逻辑。_Grunnable
变为 _Grunning
,P0 的状态保持或变为 _Prunning
。M0 的 m.curg
指向 G1。随后,M0 从 g0
栈切换到 G1 的栈,开始执行 G1 的代码。g0
栈。G1 的状态变为 _Gdead
,其资源会被回收。M0 (在 g0
栈上) 接着会再次尝试从 P0 的 LRQ 寻找下一个可运行的 G。g0
栈上)会尝试进行 工作窃取 (work-stealing) ,它会随机查看其他 P 的 LRQ,如果发现有 G,就窃取一半过来放到自己的 P0 的 LRQ 中。如果其他 P 的 LRQ 也都为空,M0 会尝试从 GRQ 获取 G。_Pidle
状态,并解除 M0 与 P0 的关联,M0 自身也可能进入休眠(park)状态,等待新的 G 到来时被唤醒。或者,M0 会去自旋(spinning)一段时间,期望短期内有新的 G 产生。自旋 (spinning) 是指 M 在一个紧密的循环中不断检查是否有可运行的 G,而不立即放弃 CPU。
自旋是一种短期内积极寻找任务的优化手段,适用于预期任务会很快出现的场景,以减少调度开销,但它确实会短暂地增加 CPU 使用率。
在这个过程中,P 的状态也会相应变化。例如,当一个 M 成功与一个 P 绑定并开始查找或执行 G 时,P 的状态会是 _Prunning
。如果 P 的 LRQ 和 GRQ 都长时间为空,并且没有 M 依附于它,它可能进入 _Pidle
状态。
G 的栈数据切换发生在 M 从 g0
栈切换到用户 G 的栈,以及从用户 G 的栈切回 g0
栈时。这个切换操作会保存和恢复各自的栈指针和寄存器等上下文信息。
2. 栈的伸缩与 P 的竞争
morestack
的机制。该机制会分配一个新的、更大的栈段,并将旧栈的内容拷贝到新栈段,然后 G 继续在新栈上执行。这个过程对用户是透明的。当函数返回,栈使用量减少时,虽然不会立即缩小,但在垃圾回收期间,如果发现栈使用率过低,可能会进行栈的收缩(shrinkstack
)。GOMAXPROCS
创建相应数量的 P。如果 M 的数量少于 P 的数量(例如,某些 M 因为系统调用阻塞了),或者有空闲的 P 和待运行的 G,运行时可能会唤醒或创建新的 M 来绑定这些 P。一个 M 必须获取到一个 P 才能运行 Go 代码。如果所有 P 都在 _Prunning
状态(即都有 M 在其上运行 G),那么新创建的 G 只能进入 LRQ 或 GRQ 等待。当一个 M 从阻塞的系统调用返回,或者一个 G 执行完毕,它会尝试获取一个 P 来继续执行。3. I/O 操作与网络调度
当一个 G (假设为 Gx,在 M1/P1 上运行) 发起一个阻塞性的 I/O 操作,比如网络读写时,情况会变得特殊:
read
。Go runtime 的 syscall
包中的函数通常会进行特殊处理。M1 会即将进入阻塞状态。_Gsyscall
。这里需要考虑到调度器内部实现的复杂性和一些边缘情况。核心原则是: LRQ 始终与 P 绑定 。当 M1 因 Gx 的系统调用而将要阻塞时,它会释放 P1。
pidle
列表)。其 LRQ 中的 G 仍然与 P1 绑定并处于 _Grunnable
状态。一旦有 M 可用(例如 M1 从系统调用返回后变为空闲,或者 sysmon
检测到需要更多 M 并创建/唤醒了一个),这个 M 就会从 pidle
列表中获取 P1,并开始执行其 LRQ 中的 G。epoll
,在 macOS 上基于 kqueue
,在 Windows 上基于 iocp
)。当 Gx 发起网络 I/O 时,其对应的文件描述符会被注册到这个网络轮询器中。M1 线程本身会阻塞在系统调用上(或者对于非阻塞 I/O,G 会等待 netpoller 的通知),但它不再持有 P。_Grunnable
,并被放回到某个 P 的 LRQ (可能是原来的 P1,如果它恰好空闲) 或者 GRQ 中。Go 的标准库网络操作在底层通常被封装为非阻塞模式,并与 netpoller 集成。
net.Conn.Read()
时,如果数据尚未到达,runtime 不会真的让 M1 线程阻塞在内核的 read()
调用上。相反,它会将 Gx 的状态置为 _Gwaiting
,并将与该连接对应的文件描述符 (FD) 注册到 netpoller 中,请求 netpoller 在该 FD 可读时通知。然后,M1 释放 P1(或 P1 被其他 M 接管),M1 可以去执行其他 G 或者休眠。sysmon
驱动) 使用操作系统提供的事件通知机制 (如 epoll_wait
,kevent
等) 来同时监控大量已注册的 FD。这些机制允许一个线程高效地等待多个 FD 上的事件,而无需为每个 FD 单独创建一个线程。read
操作)或可以发送数据(对于 write
操作)时,它会通知 netpoller。_Gwaiting
状态转换回 _Grunnable
状态,并将其放入一个运行队列 (通常是 GRQ,有时也可能是某个 P 的 LRQ,例如上次运行该 G 的 P,以期利用缓存局部性)。_Grunnable
,它就和其他等待调度的 G 一样。当某个 M/P 组合选中它执行时,它会从之前中断的地方恢复。此时,由于 netpoller 已经确认数据就绪,G 可以执行实际的、现在不会阻塞的 read()
操作来获取数据。_Grunnable
,它就和其他可运行的 G 一样,等待某个 M/P 组合来执行它。当轮到它时,它会从上次阻塞的地方继续执行。这种机制确保了少数 G 的阻塞性 I/O 不会阻塞整个程序的并发执行。M 的数量可能会根据需要动态调整(在一定范围内),以适应负载情况。
创建一个 go func(){}()
发生了什么?
当你执行一行代码 go func(){ ... }()
时,Go runtime 会执行以下步骤:
_Grunnable
,表示它已经准备好运行,只等待调度器的调度。_Grunnable
的 G 通常会被尝试放入当前 M 所关联的 P 的 LRQ。go
语句本身是一个非阻塞调用。执行 go
语句的 goroutine 会继续执行其后续代码,而不会等待新创建的 goroutine 开始或完成执行。_Grunning
等),然后开始执行该匿名函数内的代码。整个过程与上面描述的 GPM 调度机制紧密相连,新的 G 只是作为调度器可调度的一个单元被高效地管理起来。
调度策略与抢占机制
Go 的调度器采用了一些关键策略来保证公平性和效率:
select
语句、以及一些同步原语的调用点。这意味着如果一个 goroutine 执行一个没有任何函数调用的密集计算循环 (for {}
),它可能会长时间占据 M,导致同一个 P 上的其他 goroutine 饿死。从 Go 1.14 版本开始,引入了 基于信号的异步抢占机制 (asynchronous preemption) ,以解决上述问题:
sysmon
后台监控线程 :Go runtime 有一个名为 sysmon
的特殊 M(不关联 P),它会定期进行一些维护工作,其中就包括检查是否有 G 运行时间过长(例如,超过一个固定的时间片,通常是 10ms)。sysmon
发现某个 G 在一个 M 上运行时间过长,它会向该 M 发送一个抢占信号(例如,在 Unix 系统上是 SIGURG
)。_Gpreempted
或类似状态,然后被放回到运行队列(通常是 GRQ,以给其他 P 机会执行它,避免立即在同一个 P 上再次调度)。g0
栈上),选择下一个可运行的 G 来执行。这种异步抢占机制确保了即使是那些没有主动让出 CPU 的计算密集型 goroutine 也能够被公平地调度,从而提高了整个系统的响应性和并发任务的并行度。它使得调度器更加健壮,不易受到不良编写的 goroutine 的影响。
func main
也是一个 goroutine当一个 Go 程序启动时,main
包下的 main
函数并不是直接在某个原始线程上执行,而是由 Go runtime 创建的第一个用户级 goroutine,通常被称为 main goroutine 。
main.main()
函数。main
goroutine 在行为上与用户通过 go
关键字创建的其他 goroutine 是平等的。它也拥有自己的栈,受 GPM 调度器的管理,可以被抢占,也可以创建新的 goroutine。main
goroutine 的结束标志着整个程序的结束。当 main
函数返回时,Go runtime 会开始关闭程序。此时,所有其他仍在运行的 goroutine 都会被强制终止,除非程序使用了像 sync.WaitGroup
这样的机制来显式等待其他 goroutine 完成。main
函数没有返回值。程序如果正常退出,通常退出码为 0。如果发生 panic
且未被 recover
,或者调用了 os.Exit(code)
,则会以相应的状态退出。理解 main
函数本身也是一个 goroutine 有助于更好地认识 Go 的并发模型的一致性:所有用户代码都运行在 goroutine 之上,由统一的 GPM 模型进行调度和管理。这体现了 Go 在语言层面和运行时层面将并发作为一等公民的设计哲学。
原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。
如有侵权,请联系 cloudcommunity@tencent.com 删除。
原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。
如有侵权,请联系 cloudcommunity@tencent.com 删除。