深入理解 Go 语言的调度原理
引言
Go 语言(Golang)是一种强大的并发编程语言,它的高效调度器(Scheduler)是其并发能力的核心所在。Go 调度器基于 M(Machine)、P(Processor)、G(Goroutine)的设计,能够充分利用多核 CPU,实现高效的 goroutine 执行。本文将深入分析 Go 调度器的设计原理、工作机制以及其背后的调度策略。
为什么需要调度器?
在 Go 中,goroutine 是一种轻量级线程,它比传统的系统线程更轻量,因此 Go 程序通常会启动成千上万的 goroutine。但是,goroutine 需要某种机制来合理分配 CPU 时间并管理调度,以确保高效并发执行。这就是调度器存在的意义。
传统的操作系统调度器虽然能够管理线程,但其开销大、调度效率较低。而 Go 语言的调度器专为 goroutine 设计,充分优化了调度效率。
Go 调度器模型概述
Go 调度器的模型可以概括为 G-P-M:
- G(Goroutine):表示 goroutine,Go 中执行任务的最小单位,每一个 G 都包含需要执行的函数、栈空间和状态信息。
- P(Processor):表示调度器中的逻辑处理器,负责调度 goroutine。每个 P 都有一个自己的本地队列,存储待执行的 goroutine。
- M(Machine):表示工作线程(OS 线程),负责执行 G。M 绑定到 P 之后可以运行 G,一个 M 在同一时间只能绑定一个 P。
这种 G-P-M 模型可以看作是 Go 语言调度器的执行三元组。接下来,我们详细探讨它们之间的关系及其调度机制。
G、P、M 之间的关系
-
M(Machine)与 P(Processor):M 是系统线程,负责实际运行 G,但它需要 P 的支持。P 维护着 goroutine 队列,管理 goroutine 的执行。M 和 P 是一一对应的,一个 M 必须先获得一个 P,才能执行 goroutine。
-
P(Processor)与 G(Goroutine):P 是调度的核心,维护着本地 G 队列。当一个 M 与一个 P 绑定时,P 会将队列中的 goroutine 分配给 M 执行。
-
M(Machine)与 G(Goroutine):M 实际运行 G。P 分配给 M 的 G 会由 M 执行,当 G 执行完毕或阻塞时,M 会切换到下一个 G。
下图展示了 G-P-M 之间的关系:
+-----------+ +----------+ +-----------+
| G | ---> | P | ---> | M |
| goroutine | | processor| | machine |
+-----------+ +----------+ +-----------+
这种设计能够使得 goroutine 的调度更加高效,降低线程的切换成本,充分利用多核 CPU 的性能。
Go 调度器的核心组件
Go 调度器中几个关键的结构体如下:
Goroutine
Goroutine 是 Go 语言中的任务单位,通常由 g 结构体表示:
type g struct {
stack stack // goroutine 的栈
stackguard0 uintptr // 栈溢出检测
sched gobuf // goroutine 调度信息
goid int64 // goroutine ID
waitreason string // goroutine 阻塞的原因
// ... 其他字段
}
g 结构体包含了 goroutine 的运行栈、调度信息等。
Processor
P 是调度的关键逻辑单元,由 p 结构体表示:
type p struct {
id int32 // P 的 ID
runq []g // 本地队列,存储待执行的 goroutine
runqsize int32 // 本地队列长度
// ... 其他字段
}
p 结构体包含了 goroutine 的本地队列、调度器状态等信息。
Machine
M 是系统线程,由 m 结构体表示:
type m struct {
g0 *g // M 自己的 goroutine
p *p // 当前绑定的 P
nextp *p // 待绑定的 P
// ... 其他字段
}
m 结构体包含了当前正在运行的 goroutine、绑定的 P 等信息。
Go 调度的工作流程
1. Goroutine 创建与调度
当我们在代码中启动一个新的 goroutine 时,它会被创建成一个新的 G 对象,随后被加入到一个 P 的本地队列中。如果该 P 目前没有空闲的 M,那么 goroutine 就会被挂起,等待执行。
2. M 绑定 P 执行 Goroutine
一个 M 需要绑定一个 P 才能执行 goroutine。P 中的调度器会从其本地队列中取出待执行的 G,然后交给 M 执行。当一个 G 被执行完毕后,M 会继续从 P 的队列中取出下一个 G 进行执行。
3. Work Stealing
如果一个 P 的本地队列为空,而另一个 P 的本地队列中有很多 G,那么 Go 调度器会尝试进行“窃取”操作,称为 work stealing。它会从其他 P 的本地队列中“偷”一些 G,以保证各 P 之间的负载均衡,提高并发性能。
4. 调度触发机制
Go 调度器采用协作式调度,即 goroutine 在以下情况会触发调度:
- 系统调用或 I/O 操作时,goroutine 阻塞。
- 主动调用
runtime.Gosched(),手动让出 CPU 时间。 - goroutine 执行时间过长,超出了时间片。
Go 调度策略
Go 调度器的调度策略可以归结为以下几个方面:
-
M 与 P 的数量:默认情况下,
P的数量等于 CPU 核心数(可以通过runtime.GOMAXPROCS()设置),这确保了调度器能够充分利用多核 CPU。 -
G 队列调度:每个
P都有自己的本地队列,这样可以最大程度减少线程间的通信开销,提高性能。 -
work stealing:为了避免任务不均衡,Go 调度器会尝试从负载较重的
P中窃取G,以实现任务的均衡分配。 -
抢占式调度:Go 1.14 引入了抢占式调度,调度器会定期检查运行中的 goroutine,如果发现某个 goroutine 执行时间过长,会强制中断它的执行,将控制权交给其他 goroutine。
调度器的演进与优化
Go 的调度器在各个版本中不断优化,从早期的 G-M 模型演进到 G-P-M 模型,再到 1.14 版本引入抢占式调度,大幅提升了 Go 的并发性能。同时,Go 调度器一直在追求简洁、高效、低开销的目标,适用于大规模并发场景。
总结
Go 调度器是 Go 并发编程的核心,它基于 G-P-M 模型,采用协作式和抢占式调度相结合的策略,实现了高效的 goroutine 调度。通过合理分配任务和充分利用多核资源,Go 调度器在高并发场景中表现出色,是 Go 并发编程强大的基础。
希望这篇文章能帮助你更好地理解 Go 调度器的工作原理,并在并发编程中更好地利用 Go 语言的优势。

浙公网安备 33010602011771号