深入 Go 底层原理(三):Goroutine 的调度策略
1. 引言
Goroutine 是 Go 语言并发的基石,它常被称为“轻量级线程”。与操作系统(OS)线程相比,Goroutine 的创建和销毁成本极低,上下文切换也快得多。这得益于 Go 语言在 runtime
中实现的一套高效的用户态调度系统。
本文将宏观地介绍 Goroutine 的调度策略,包括其 M:N 模型、工作窃取(Work-Stealing)和抢占机制,为下一篇深入 GMP 模型打下基础。
2. M:N 调度模型
Go 的调度器采用的是 M:N 模型。
M (Machine): 代表内核线程(OS Thread)。
N (Goroutine): 代表 Go 语言的用户态协程。
M:N 模型意味着,调度器会将 N 个 Goroutine 动态地、多路复用地调度到 M 个内核线程上执行。通常情况下,M 的数量远小于 N 的数量(M 约等于 CPU 核心数)。
这种模型的优势在于:
低成本切换:Goroutine 的上下文切换完全在用户态完成,不涉及内核态和用户态的转换,成本非常低(只需保存几个寄存器,如 PC, SP)。
高效利用资源:当一个 Goroutine 因系统调用(如 I/O)或 channel 操作而阻塞时,调度器会将其从内核线程 M 上摘下,并让 M 去执行另一个可运行的 Goroutine,从而避免了线程的空闲和浪费。
3. 核心调度策略:工作窃取 (Work-Stealing)
为了让所有内核线程都尽可能地“忙碌”起来,Go 调度器采用了一种非常有效的负载均衡策略——工作窃取。
在 GMP 模型中(详见下篇),每个处理器 P
(Processor) 都有一个自己的本地可运行 Goroutine 队列(Local Run Queue, LRQ)。调度流程如下:
优先本地队列:当一个内核线程
M
准备执行 Goroutine 时,它会优先从其绑定的P
的本地队列中获取G
。这个过程不需要加锁,非常高效。尝试全局队列:如果
P
的本地队列为空,M
会尝试从全局可运行队列(Global Run Queue, GRQ) 中获取一批G
到自己的本地队列。访问全局队列需要加锁。工作窃取:如果全局队列也为空,
M
将会变身为一个“小偷”,它会随机地选择另一个处理器P'
,并尝试从P'
的本地队列中**“窃取”**一半的 Goroutine 到自己的本地队列中。
工作窃取机制极大地提高了调度器的效率和并行度,确保了当一个 P
的任务繁重时,空闲的 P
可以主动过来分担,实现了任务的动态负载均衡。
4. Goroutine 的抢占 (Preemption)
如果一个 Goroutine 长时间占用一个线程 M
(例如,进行密集的计算),其他 Goroutine 就会“饿死”。为了防止这种情况,Go 需要一种抢占机制,让出 CPU 给其他 Goroutine。
Go 的抢占经历了两个阶段:
协作式抢占 (Go 1.14 之前)
编译器在函数调用的入口处插入一些“抢占检查”代码。
当 Goroutine 进行函数调用时,会检查一个全局的抢占标记。如果标记被设置,它会主动让出 CPU。
缺点:如果一个 Goroutine 只是在一个没有函数调用的
for
循环中进行密集计算,它将永远不会被抢占。
基于信号的异步抢占 (Go 1.14 及以后)
Go
runtime
会启动一个名为sysmon
的监控线程。sysmon
会定期检查所有正在运行的 Goroutine。如果发现某个G
运行时间超过了一个阈值(如 10ms),它会向该G
所在的线程M
发送一个抢占信号。M
接收到信号后,会中断当前G
的执行,将其重新放回队列,然后执行其他G
。
这种异步抢占机制解决了协作式抢占的痛点,保证了即使在没有函数调用的“死循环”计算中,Goroutine 也能被公平地调度。
5. 总结
Go 语言的 Goroutine 调度器是一个设计精良、高效的系统。它通过 M:N 模型实现了 Goroutine 与内核线程的解耦,通过工作窃取策略实现了负载均衡,并通过异步抢占机制保证了调度的公平性。这些策略共同构成了 Go 高并发性能的基石。下一篇,我们将深入构成这一切的 GMP 三大组件。