Note
Go 的 GMP 调度模型
解释 Goroutine、操作系统线程和逻辑处理器如何协作,以及运行队列、工作窃取、网络轮询、系统调用和抢占调度。
Go 的 GMP 调度模型
Go 的并发编程有一个很容易被忽略的特点:创建 Goroutine 很简单,但让成千上万个 Goroutine 在有限的操作系统线程上高效运行,并不简单。
假设一个服务同时处理数万个请求。如果每个请求都创建一个操作系统线程,线程栈、上下文切换和内核调度都会带来额外开销。Go 选择了另一条路:使用轻量级的 Goroutine 表示并发任务,再由运行时调度器把这些任务分配到操作系统线程上执行。
Goroutine 本身并不负责执行代码。真正执行指令的是操作系统线程,而 Go 运行时需要解决的是:哪些任务可以执行、由哪个线程执行、线程何时切换任务,以及任务阻塞后如何继续利用 CPU。
GMP 模型就是这套调度机制的核心。
一、G、M、P 分别是什么
GMP 是三个运行时概念的组合:
- G(Goroutine):代表一个 Go 并发任务。
- M(Machine):代表一个操作系统线程。
- P(Processor):代表执行 Go 代码所需的逻辑处理器资源。
理解这三个概念,最重要的是分清任务、执行线程和执行资源之间的关系。
1. G:待执行的任务
每次调用 go 语句,Go 运行时都会创建一个新的 Goroutine。
package main
import (
"fmt"
"time"
)
func main() {
go func() {
fmt.Println("hello from goroutine")
}()
time.Sleep(time.Second)
}
这里的匿名函数会在一个新的 Goroutine 中执行。运行时会为它维护相应的调度状态、栈和执行上下文。
Goroutine 的栈可以根据需要增长,不需要像传统线程那样一开始就为每个任务分配较大的固定栈空间。这也是 Go 能够支持大量 Goroutine 的原因之一。
不过,轻量并不意味着没有成本。每个 Goroutine 仍然需要占用内存,也会产生调度和同步开销。创建大量 Goroutine 不一定有问题,但如果它们同时积压、频繁唤醒或持有大量资源,仍然可能影响程序性能。
2. M:真正执行代码的线程
M 对应操作系统线程。无论一个任务是由哪个 Goroutine 表示,最终都需要在线程上执行。
操作系统负责调度这些线程,让它们在 CPU 核心上运行。Go 运行时则负责决定哪个 Goroutine 应该交给哪个线程执行。
一个 M 不会永久绑定某个普通 Goroutine。它可以先执行 G1,随后切换到 G2,再执行其他任务。
这意味着 Goroutine 的切换和操作系统线程的切换是两回事。很多 Goroutine 切换只需要 Go 运行时保存和恢复执行状态,并不要求操作系统进行一次线程上下文切换。
M 的数量也不一定等于 CPU 核心数。线程可能因为系统调用、cgo 调用等原因阻塞,因此运行时可以在必要时使用其他线程继续执行 Go 代码。
3. P:执行 Go 代码所需的资源
P 是 GMP 中最容易被误解的部分。
P 不是 CPU 核心,也不是操作系统线程。它是 Go 运行时内部的一种逻辑资源,包含调度器所需的状态,例如本地运行队列,以及部分内存分配缓存。
M 必须持有一个 P,才能执行普通的 Go 代码。没有 P 的 M 仍然可能存在,例如它正在执行系统调用,但不能因此继续执行普通的 Go 代码。
P 的数量由 GOMAXPROCS 控制。它决定同一时刻最多有多少个执行资源可以用于并行执行 Go 代码。
例如:
package main
import (
"fmt"
"runtime"
)
func main() {
fmt.Println("CPU cores:", runtime.NumCPU())
fmt.Println("GOMAXPROCS:", runtime.GOMAXPROCS(0))
}
runtime.NumCPU() 返回程序可见的逻辑 CPU 数量,而 runtime.GOMAXPROCS(0) 返回当前的 GOMAXPROCS 值。两者可能相同,也可能不同。
在 GOMAXPROCS=4 时,通常有 4 个 P 可以同时执行 Go 代码,但实际的 M 数量可能多于 4。
二、G、M、P 如何协作
可以把 GMP 理解为一个任务分配系统:
- G 提供需要执行的工作。
- M 提供实际运行代码的线程。
- P 提供执行 Go 代码所需的资源,并维护一部分调度状态。
它们的关系并不是简单的三层固定绑定,而是由运行时根据任务状态动态协调。
当一个 M 持有 P 时,它可以从 P 的本地运行队列中获取一个可运行的 G,执行其代码。G 执行结束、主动让出执行权、进入阻塞状态,或者被调度器抢占时,运行时会根据具体情况决定下一步如何调度。
Go Runtime
|
+-----------+-----------+
| | |
P0 P1 P2
| | |
本地队列 本地队列 本地队列
| | |
M0 M1 M2
| | |
G0 G3 G6
这张图展示的是一种常见的运行状态,而不是固定的对应关系。M 可以与 P 分离,G 也可以在不同的 M 上继续执行。
例如,M0 正在执行 G0 时,如果 G0 因等待 channel 而阻塞,运行时可以保存 G0 的状态,让 M0 转而执行其他可运行的 G。这样,一个任务等待资源时,不必让整个操作系统线程一直空等。
需要注意,P 的本地队列不是唯一的任务来源。Go 运行时还维护全局运行队列,并结合网络轮询、定时器和工作窃取等机制寻找可运行的任务。
三、运行队列:任务放在哪里
Go 调度器需要管理大量 Goroutine。为了避免每次调度都访问同一个共享队列,运行时采用了本地运行队列与全局运行队列相结合的方式。
1. P 的本地运行队列
每个 P 都有自己的本地运行队列。新创建或被唤醒的 Goroutine,在符合调度条件时,可以进入当前 P 的本地队列。
这种设计有两个好处。
首先,队列操作可以尽量局部化,减少多个线程争抢共享状态的情况。
其次,P 可以优先执行自己队列中的任务,减少不必要的跨处理器调度和任务转移。
Go 运行时还提供了 runnext 机制,用于保存一个优先考虑执行的 Goroutine。它有助于降低某些连续任务之间的调度延迟,但并不意味着该任务在所有情况下都拥有绝对优先级。
2. 全局运行队列
本地队列并不能解决所有问题。
某些任务会被放入全局运行队列,例如本地队列空间不足时转移出去的任务,以及由运行时集中交付的一部分可运行 Goroutine。
当 P 找不到合适的本地任务时,调度器可能会检查全局运行队列,从中批量获取一部分 G,再将它们放入本地队列。
批量获取可以摊薄全局锁的开销。不过,全局队列仍然是共享资源,因此运行时会尽量避免在每次调度时都访问它。
3. 调度器如何寻找下一个 G
调度器寻找任务时,并不是每次都执行同一套固定流程。它会结合当前状态、计时器、垃圾回收任务、网络轮询结果和其他调度条件决定下一步操作。
从理解原理的角度,可以把常见的任务查找过程概括为:
- 检查当前 P 的本地运行队列。
- 检查全局运行队列及相关调度任务。
- 在合适的时机检查网络轮询结果和定时器。
- 如果仍然没有任务,尝试从其他 P 窃取工作。
- 如果暂时没有可执行的工作,M 可能进入休眠,等待新的任务或调度事件。
这只是便于理解的概括,并非所有版本和所有状态下都严格按这个顺序执行。
四、工作窃取:让空闲的 P 找到工作
如果每个 P 只执行自己的本地队列,就可能出现负载不均衡的问题。
例如,P0 的队列里积压了大量任务,而 P1 的队列已经为空。此时,如果 P1 只能等待,就会浪费一部分可用的并行能力。
Go 运行时采用工作窃取机制来缓解这种情况。
当某个 P 找不到可运行的 G 时,它可以尝试从其他 P 的本地运行队列中窃取任务。通常,窃取操作会取得对方队列中的一部分任务,而不是把整个队列搬走。
窃取前:
P0: [G1] [G2] [G3] [G4] [G5] [G6]
P1: []
P1 尝试窃取
窃取后:
P0: [G1] [G2] [G3]
P1: [G4] [G5] [G6]
这样,原本集中在 P0 上的工作可以分散到 P1 上执行。
工作窃取并不能保证每个 P 永远拥有相同数量的任务。任务的执行时间可能差异很大,新的 G 也会不断创建,某些任务还可能随时阻塞。调度器的目标是尽量利用可用的执行资源,而不是让所有队列在每一刻都保持完全均衡。
工作窃取也不是没有成本。跨队列访问需要同步和原子操作,窃取失败后还需要继续寻找工作。因此,调度器会在负载均衡和调度开销之间进行权衡。
五、系统调用:M 阻塞后会发生什么
系统调用是理解 M 与 P 分离机制的重要场景。
假设某个 Goroutine 正在执行文件操作,调用进入了可能长时间阻塞的系统调用。如果它所在的 M 一直占有 P,那么其他可运行的 G 就可能无法及时获得这个执行资源。
Go 运行时会尽可能避免这种情况。
当 M 进入需要处理的阻塞式系统调用时,运行时可以让它与 P 分离。P 被交还给运行时,随后可以交给其他 M 使用。其他 M 持有 P 后,就能继续执行可运行的 Go 代码。
系统调用前:
M1 + P1 -> G1
M1 进入阻塞式系统调用:
M1 -> 系统调用
P1 -> 交还给运行时
其他线程获得 P1:
M2 + P1 -> G2
当系统调用返回时,原来的 M 不一定能立即重新获得原来的 P。它需要根据当时的调度状态重新获取执行资源。如果没有可用的 P,相关 Goroutine 就可能暂时等待,或者由运行时安排其他执行路径。
这里有一个重要区别:系统调用不一定都会导致 M 长时间阻塞。某些调用可以很快返回,运行时会根据具体路径处理;cgo 调用也涉及额外的线程和运行时规则。
因此,不能把所有系统调用简单理解为“必然释放 P”,也不能认为所有阻塞都会创建新线程。
六、网络轮询:等待连接不必占住线程
网络服务通常有大量连接,但大多数连接并不是每时每刻都在传输数据。
如果每个等待网络数据的 Goroutine 都独占一个操作系统线程,线程数量就可能随着连接数不断增长。
Go 通过网络轮询器(netpoll)与调度器协作,处理许多网络 I/O 等待场景。
以一个等待 TCP 数据的 Goroutine 为例:
- Goroutine 发起网络读取操作。
- 如果数据已经可读,读取操作可能直接完成。
- 如果数据暂时不可用,运行时可以将 Goroutine 挂起,并注册相应的网络等待事件。
- 该 Goroutine 不再占用当前 M 持续等待,M 可以继续执行其他任务。
- 当网络轮询器发现连接满足相应条件时,运行时将等待中的 Goroutine 标记为可运行。
- 调度器之后会安排它继续执行。
G1 发起网络读取
|
v
数据暂时不可用
|
v
G1 进入等待状态
|
+------ M 继续执行其他 G
|
v
netpoll 发现网络事件
|
v
G1 变为可运行
|
v
调度器重新安排 G1
这里的网络轮询器并不是一个为每个连接单独创建的线程。它会利用操作系统提供的 I/O 多路复用机制,例如 Linux 上的 epoll,以及其他平台对应的机制。
同时,netpoll 并不意味着所有 I/O 都是非阻塞的,也不意味着任何文件操作都会自动采用相同的等待机制。具体行为取决于操作系统、文件描述符类型和 Go 运行时的实现。
理解这一点,有助于解释为什么 Go 能够用相对有限的线程处理大量网络连接:等待中的 Goroutine 可以被挂起,而线程和 P 可以继续服务其他可运行任务。
七、Goroutine 的阻塞、让出与唤醒
Goroutine 并不只有“正在运行”和“已经结束”两种状态。
在调度过程中,一个 G 可能处于以下状态:
- 可运行:已经具备执行条件,等待调度器分配执行资源。
- 运行中:正在某个 M 上执行。
- 等待中:等待 channel、锁、网络事件、定时器或其他条件。
- 已结束:函数执行完成,G 不再继续执行用户代码。
例如:
ch := make(chan int)
go func() {
value := <-ch
println(value)
}()
ch <- 42
接收操作执行时,如果 channel 中没有可接收的数据,接收方 Goroutine 会进入等待状态。它不会因为等待而一直占用一个 M 执行空转。
发送方将数据发送到 channel 后,运行时会协调接收方的唤醒。被唤醒的 G 进入可运行状态,之后由调度器安排执行。
唤醒并不等于立即运行。被唤醒的 G 仍然需要等待合适的执行资源,也可能与其他可运行任务竞争调度机会。
锁、条件变量、定时器以及网络等待的具体实现各有区别,但它们都会与 Goroutine 的状态转换和调度机制发生联系。
八、抢占调度:避免一个 G 长时间占用执行机会
如果一个 Goroutine 一直执行计算任务,从不主动让出执行权,其他 G 就可能长时间得不到运行机会。
例如:
func busyLoop() {
for {
// 持续执行计算
}
}
这样的循环没有主动阻塞,也没有主动结束。调度器需要具备抢占能力,才能在适当的时候让其他 Goroutine 获得执行机会。
Go 的抢占机制经历过演进。较早的实现主要依赖函数调用等安全检查点;现代 Go 运行时还支持异步抢占,可以在满足安全条件时暂停正在执行的 Goroutine。
抢占的目的并不是保证每个 Goroutine 获得完全相同的 CPU 时间,而是让运行时能够控制长时间运行的任务,改善整体调度和垃圾回收的可进行性。
1. 协作式调度与抢占式调度
协作式调度依赖任务在适当的位置主动让出执行机会,或经过运行时检查点进入调度流程。
抢占式调度则允许运行时在必要时请求暂停正在运行的 Goroutine,而不完全依赖它主动调用 runtime.Gosched() 或进入阻塞操作。
两者并不是互斥的。Go 运行时会根据代码执行位置、运行时状态和安全条件,通过不同路径完成调度。
2. 抢占不是任意指令处都能无条件进行
抢占需要考虑栈和寄存器中的指针信息、垃圾回收安全性,以及运行时内部状态。
因此,即使现代 Go 支持异步抢占,也不能简单理解为“任何时候都可以毫无条件地暂停任意指令”。
此外,抢占请求发出后,实际完成抢占还需要满足运行时的安全条件。对于需要精确时序的程序,也不能依赖调度器提供固定的时间片或严格公平的执行顺序。
九、去掉 P 会发生什么
理解了 P 的职责之后,就能更准确地分析一个问题:如果 Go 只保留 G 和 M,会怎么样?
首先,G 仍然可以表示并发任务,M 仍然可以代表操作系统线程。没有 P 并不意味着多线程执行或并行计算在理论上无法实现。
真正的问题在于:当前由 P 承担的调度与资源管理职责,需要放在哪里?
1. 任务调度可能更容易出现共享竞争
如果采用最简单的 GM 设计,让所有 M 都从同一个全局运行队列获取 G,那么任务入队、出队就可能集中竞争同一把锁。
在大量 Goroutine 频繁创建、唤醒和切换的场景下,这会增加调度开销。
但这不是所有无 P 调度器的必然结果。如果改用分片队列、线程本地队列或其他同步机制,也可以缓解共享竞争。问题在于,这些机制同样需要管理任务分布和负载均衡。
2. 线程与调度状态之间需要重新划分职责
P 让 Go 运行时能够把部分调度状态和缓存放在逻辑处理器上,而不是完全绑定到某个操作系统线程。
如果取消 P,就需要重新设计这些状态的归属。例如,缓存可以绑定到 M,也可以由其他资源管理机制维护;运行队列可以采用共享队列,也可以进行分片。
不同设计会产生不同的内存占用、同步成本和局部性表现。因此,不能仅凭取消 P 就断言内存分配一定变慢,但原有的资源管理方式确实需要改变。
3. 系统调用阻塞时仍需解决资源交接
没有 P 并不妨碍调度器在系统调用阻塞后继续执行其他任务,但运行时仍然需要决定:
- 哪个线程可以继续执行其他 G;
- 如何唤醒或创建合适的线程;
- 如何避免线程数量不受控制;
- 如何在任务到来时及时恢复并行执行能力。
GMP 通过 M 与 P 的分离机制明确处理了这类问题。去掉 P 后,仍然需要有相应的机制承担这些职责。
4. 工作窃取机制可能需要替换
如果所有 M 都从同一个共享队列获取任务,就不一定需要原样保留基于 P 的工作窃取机制。
但如果采用多个本地队列,就仍然需要某种负载均衡方式,避免某些线程或队列任务积压,而其他线程无事可做。
因此,取消 P 并不会自动消除工作窃取所解决的问题,只是改变了问题的组织方式。
5. 多核并行能力取决于替代调度设计
Go 仍然可以让多个 M 同时运行不同的 G。只要调度器允许线程并行执行,并且没有其他瓶颈,多核并行就仍然可行。
但是,如果调度状态高度集中,线程频繁争抢共享锁,多核扩展性就可能受到限制。
所以,P 并不是并行计算在理论上的必要条件,而是 Go 运行时用来管理执行资源、减少共享竞争的一种关键设计。
从这个角度看,去掉 P 并不只是删除一个结构体,而是需要重新设计它承担的调度、缓存和资源协调职责。
十、GMP 与 GOMAXPROCS 的关系
GOMAXPROCS 决定同一时刻可以并行执行多少个 Go 任务的逻辑上限。
例如:
runtime.GOMAXPROCS(2)
这会将 GOMAXPROCS 设置为 2,使 Go 运行时通常只使用两个 P 来执行 Go 代码。
它不意味着程序最多只能创建两个 Goroutine,也不意味着进程只能有两个操作系统线程。
一个程序可以创建成千上万个 G。运行时会让有限数量的 P 和 M 执行这些任务,并在任务阻塞、唤醒、结束或被抢占时调整调度。
如果程序主要执行 CPU 密集型计算,增加 GOMAXPROCS 可能提高并行度,但效果还受到 CPU 资源、内存带宽、锁竞争和任务粒度等因素影响。
如果程序主要等待网络 I/O,那么大量 Goroutine 可以在较少的执行资源上交替运行,GOMAXPROCS 的影响方式就会有所不同。
因此,调整 GOMAXPROCS 并不是简单地调整线程数,而是在控制 Go 代码的逻辑并行度。
十一、如何观察调度器的实际行为
理解调度器不能只停留在概念图上。Go 提供了一些工具,可以观察程序中的 Goroutine、线程和调度事件。
1. 查看运行时统计信息
package main
import (
"fmt"
"runtime"
)
func main() {
fmt.Println("Goroutines:", runtime.NumGoroutine())
fmt.Println("OS threads:", runtime.GOMAXPROCS(0))
}
这里第二行输出的是 GOMAXPROCS,不是操作系统线程数。标准库没有通过 runtime.NumGoroutine() 对应的简单接口直接返回当前 M 的数量。
可以通过 runtime/metrics 获取部分运行时指标,例如 Goroutine 数量、堆内存和垃圾回收相关数据。但具体指标能否直接反映调度瓶颈,需要结合程序行为分析。
2. 使用调度器调试日志
运行程序时,可以设置:
GODEBUG=schedtrace=1000 go run .
schedtrace 会按指定的毫秒间隔输出调度器的统计信息,其中包含调度资源和线程状态等内容。
如果需要更详细的信息,可以尝试:
GODEBUG=schedtrace=1000,scheddetail=1 go run .
详细日志可能很长,适合在小规模复现程序中观察,不建议直接在高流量生产环境中随意开启。
3. 使用 Go 执行追踪
Go 的 runtime/trace 能够记录 Goroutine 调度、系统调用、网络等待以及其他运行时事件。通过 go tool trace 可以查看执行时间线,分析 Goroutine 为什么等待、什么时候被唤醒,以及运行时如何安排执行。
例如:
go test -trace trace.out ./...
go tool trace trace.out
这要求测试程序支持相应的测试命令,并且运行过程中确实产生了可分析的调度事件。
对于性能问题,追踪信息通常比单纯查看 Goroutine 数量更有价值,因为它能帮助区分 CPU 执行、阻塞等待和调度延迟。
十二、总结
GMP 的价值不在于把三个字母组合起来,而在于它们各自承担不同的职责。
G 表示需要执行的任务,M 表示真正执行代码的操作系统线程,P 则提供执行 Go 代码所需的调度资源,并维护部分本地状态。
本地运行队列减少了对共享队列的依赖,工作窃取帮助分散不均衡的任务,系统调用处理让阻塞线程与执行资源分离,网络轮询让许多等待 I/O 的 Goroutine 不必一直占用线程,而抢占调度则帮助运行时控制长时间运行的任务。
这些机制共同构成了 Go 调度器的工作方式。
去掉 P 并不意味着 Goroutine 不能并发执行,也不意味着多核并行不再可能。但原本由 P 承担的调度状态、资源管理和任务分配职责,必须由其他机制接替。如果只是简单地让所有线程竞争同一个全局队列,就容易引入新的共享竞争和扩展性问题。
理解 GMP,最终要理解的是一件事:Go 如何在有限的操作系统线程和 CPU 执行资源上,管理大量生命周期不同、运行状态不断变化的 Goroutine。