提示:文章写完后,目录可以自动生成,如何生成可参考右边的帮助文档
文章目录
- 前言
- [GMP 的组成](#GMP 的组成)
- [GMP 模型的核心思想与调度流程](#GMP 模型的核心思想与调度流程)
- [总结:GMP 模型的优势](#总结:GMP 模型的优势)
- 什么是starvation(饥饿)
-
- 一个生活化的比喻
- 编程中的常见原因
-
- [优先级反转 (Priority Inversion):](#优先级反转 (Priority Inversion):)
- 不当的调度算法:
- 资源竞争与锁的不当使用:
- [Channel 的不当使用:](#Channel 的不当使用:)
- [Go 语言中如何缓解和避免 "饥饿(starvation)"?](#Go 语言中如何缓解和避免 “饥饿(starvation)”?)
- 总结
前言
Goroutine, M, P 的调度模型。这是 Go 语言并发编程的基石,也是其能够高效处理大量并发任务的关键所在。
GMP 的组成
GMP 模型由三个核心部分构成,它们共同协作完成 Goroutine 的调度和执行。
- G: Goroutine (协程)
- 是什么? Goroutine 是 Go 语言层面的轻量级 "线程",是用户态的并发执行单元。
- 特点:
- 轻量:初始栈大小仅为 2KB,且可动态伸缩(通常最大可达 1GB),远比操作系统线程(一般为 1MB 栈)占用内存少。
- 用户态:由 Go 运行时(Runtime)管理,而非操作系统内核直接调度。
- M:N 调度:多个 Goroutine (G) 可以 multiplexed (多路复用) 到少量的操作系统线程 (M) 上执行。
- 本质:你可以把 Goroutine 看作是一个包含了函数入口地址、栈信息、程序计数器以及其他执行状态的结构体。
- M: Machine (操作系统线程)
- 是什么? M 代表一个真实的操作系统线程(OS Thread)。
- 作用:它是 Goroutine 执行的 "载体"。一个 M 在某个时刻只能执行一个 Goroutine。
- 管理:M 由 Go 运行时管理,当需要时会向操作系统申请创建,当 idle(空闲)时也可能被回收,以节省资源。
- P: Processor (逻辑处理器)
- 是什么? P 是一个 "逻辑处理器",它是连接 G 和 M 的桥梁,也是 Go 调度模型中的核心。
- 作用:
- 持有上下文:P 保存了当前的调度上下文,包括一个 Goroutine 队列(Run Queue)。
- 调度 Goroutine:P 负责从自己的 Run Queue 中选取一个 Goroutine,并把它放到一个 M 上执行。
- 控制并发度:Go 程序的并发度由 GOMAXPROCS 环境变量或 runtime.GOMAXPROCS(n) 函数决定,它的值就是 P 的数量。这意味着,在任何时刻,最多有 GOMAXPROCS 个 Goroutine 在同时运行(每个 P 对应一个 M,每个 M 运行一个 G)。
GMP 模型的核心思想与调度流程
Go 调度器的核心目标是:高效地在多个操作系统线程上调度大量的 Goroutine,充分利用多核 CPU 的性能,并隐藏线程创建和切换的开销。
其基本工作流程可以概括为以下几点:
- Goroutine 创建:当你使用 go func() 创建一个新的 Goroutine (G) 时,它会被放入某个 P 的本地 Run Queue (LRQ) 中。
- P 与 M 的绑定:每个 P 会尝试绑定一个 M。当 P 的 LRQ 中有 G 等待执行,且它还没有绑定 M 时,调度器会从 M 池中找一个空闲的 M,或者创建一个新的 M,并将其与该 P 绑定。
- 执行 Goroutine:绑定了 P 的 M 会从 P 的 LRQ 中取出一个 G,然后切换到该 G 的上下文并执行它。
- Goroutine 阻塞与唤醒:
- 如果一个 G 在执行过程中发生阻塞(例如,调用 time.Sleep、等待锁 sync.Mutex、进行网络 I/O 等),M 会被阻塞。
- 此时,Go 运行时会将这个 G 从 M 上剥离下来,并将其放入相应的等待队列(例如,timer 等待队列、锁等待队列等)。
- 然后,M 会被 "解绑" 并归还给 M 池,而 P 则可以继续从自己的 LRQ 中选取下一个 G,并绑定另一个空闲的 M 来执行。
- 当阻塞条件解除后(例如,sleep 时间到、获取到锁、I/O 操作完成),对应的 G 会被重新唤醒,并被放回到某个 P 的 LRQ(可能是原来的 P,也可能是其他 P)中,等待再次被调度执行。
- Work-Stealing (工作窃取):这是 GMP 模型中一个非常聪明的优化。
- 当一个 P 的 LRQ 中的 G 都执行完了,它会变成 "空闲" 的。
为了充分利用 CPU,这个空闲的 P 会去 "窃取" 其他繁忙的 P 的 LRQ 中的 G(通常是从队列尾部窃取一半)。 - 如果所有 P 的 LRQ 都空了,P 还会去检查全局 Run Queue (GRQ)。
- 这种机制确保了所有的 CPU 核心都能得到充分利用,避免了 "忙闲不均" 的情况。
- 当一个 P 的 LRQ 中的 G 都执行完了,它会变成 "空闲" 的。
总结:GMP 模型的优势
相比于传统的线程模型或其他语言的协程模型,Go 的 GMP 模型具有以下显著优势:
- 极高的并发性能:由于 Goroutine 非常轻量,一个 Go 程序可以轻松创建成千上万甚至上百万个 Goroutine,而不会对系统资源造成太大压力。
- 高效的调度:
- M:N 调度:将大量的用户态 Goroutine 映射到少量的内核态线程上,减少了内核线程切换的开销(这是非常昂贵的操作)。
- Work-Stealing:确保了系统负载的均衡,最大化 CPU 利用率。
- 简化并发编程:开发者可以像写同步代码一样写并发代码,只需在函数调用前加上 go 关键字即可。Go 运行时会负责底层的调度细节,开发者无需关心线程的创建、管理和切换。
- 良好的阻塞处理:当 Goroutine 阻塞时,M 可以被释放去执行其他 Goroutine,而不是一直等待,这大大提高了系统的吞吐量。
补充说明
- GOMAXPROCS:这个环境变量或函数调用是控制 Go 程序并发度的关键。它设置了 P 的最大数量,也就限制了程序在同一时间最多能有多少个 Goroutine 在实际运行。默认值是 CPU 的核心数。
- 全局 Run Queue (GRQ):除了每个 P 有自己的本地 Run Queue (LRQ),还有一个全局的 Run Queue。一些特殊情况下创建的 Goroutine 或被唤醒的 Goroutine 可能会被放入 GRQ。P 在自己的 LRQ 为空时,也会去 GRQ 中寻找工作。
什么是starvation(饥饿)
在计算机科学,特别是并发编程和操作系统领域,"饥饿" 是指一个或多个进程、线程或 Goroutine 长时间无法获得其所需的资源(如 CPU 时间、内存、锁等),从而导致其任务无法继续执行或被严重延迟的现象。
一个生活化的比喻
想象一下,你在一家非常繁忙的餐厅吃饭。
- 你 (一个 Goroutine):需要服务员 (CPU) 来为你点菜、上菜。
- 其他桌的客人 (其他 Goroutines):也需要服务员。
- 服务员 (CPU):餐厅里服务员的数量是有限的(比如 GOMAXPROCS 个)。
"饥饿" 就好比,由于其他桌的客人(比如一桌非常吵闹、点了非常多菜、或者一直在加菜的客人)总是能吸引到服务员的注意,导致你这一桌的服务员迟迟不来,你已经饥肠辘辘,却一直无法下单和吃到东西。你这个 "进程" 就因为得不到 "CPU" 这个关键资源而 "饥饿" 了。
编程中的常见原因
在 Go 语言或其他并发编程环境中,导致 "饥饿" 的常见原因包括:
优先级反转 (Priority Inversion):
- 场景:一个低优先级的 Goroutine (G1) 持有了一个高优先级 Goroutine (G2) 所需要的锁。此时,G2 必须等待 G1 释放锁。
- 问题:如果还有很多中等优先级的 Goroutine 在运行,它们可能会一直抢占 CPU,导致低优先级的 G1 无法获得 CPU 时间来执行并释放锁。结果就是,最高优先级的 G2 反而因为低优先级的 G1 而无法运行,造成了 "饥饿"。
不当的调度算法:
- 如果调度器的算法是 "先来先服务"(FCFS),那么一个长任务后面的短任务就必须等待很长时间。
- 在 Go 的 GMP 模型中,虽然有 work-stealing 机制来均衡负载,但如果一个 Goroutine 本身不产生任何阻塞(比如一个无限循环的计算密集型任务),它会一直占用 CPU,直到它主动放弃(例如通过 runtime.Gosched())或者被系统调用等事件阻塞。在这种情况下,同一个 P 上的其他 Goroutine 就会 "饥饿"。不过,现代的调度器通常会有时间片轮转(Time Slicing)的机制来防止这种情况,强制让长时间运行的 Goroutine 让出 CPU。
资源竞争与锁的不当使用:
- 惊群效应 (Thundering Herd):当一个锁被释放时,大量等待该锁的 Goroutine 会被同时唤醒,但只有一个能获得锁,其余的会再次进入休眠。这会导致大量的上下文切换开销,并且可能让某些 Goroutine 多次尝试都失败,从而产生 "饥饿" 感。
- 长时间持有锁:一个 Goroutine 在持有锁期间执行了非常耗时的操作(如 I/O、复杂计算),会导致其他所有等待该锁的 Goroutine 长时间阻塞。
Channel 的不当使用:
如果一个 Goroutine 一直在等待从一个 channel 中接收数据,但这个 channel 永远不会有数据发送进来(或者发送者已经退出),那么这个 Goroutine 就会永远阻塞,造成 "饥饿"(实际上是 "死锁" 的一种特殊情况)。
Go 语言中如何缓解和避免 "饥饿(starvation)"?
Go 的 GMP 调度模型本身已经做了很多工作来减少 "饥饿" 的可能性:
- Work-Stealing 调度:空闲的 P 会主动去 "窃取" 其他 P 的任务,这保证了计算资源能被更均匀地利用。
- 抢占式调度:Go 运行时会监控长时间运行的 Goroutine,如果一个 Goroutine 运行时间过长(比如超过 10ms),调度器会强制将其抢占,让其他 Goroutine 有机会运行。这在很大程度上避免了一个 Goroutine 独占 CPU 的情况。
作为 Go 开发者,你可以采取以下策略来进一步避免 "饥饿":
- 保持锁的粒度小、持有时间短:尽量只在必要的代码段上加锁,并且在锁内避免执行耗时操作。
- 避免长时间运行的 Goroutine:如果必须执行长时间的计算,可以将其拆分成多个小任务,或者在适当的地方调用 runtime.Gosched() 主动让出 CPU 时间片,给其他 Goroutine 运行的机会。
bash
for i := 0; i < 1000000000; i++ {
// 执行一些计算...
if i % 100000 == 0 {
runtime.Gosched() // 主动让出CPU
}
}
- 使用 buffered channel 或 select 语句:在使用 channel 时,合理设置缓冲区可以避免发送者或接收者不必要的阻塞。使用 select 语句并配合 default 分支或 time.After 可以防止 Goroutine 在单个 channel 操作上永久阻塞。
- 避免优先级反转:在设计时尽量避免低优先级任务持有高优先级任务所需的资源。如果无法避免,可以考虑使用优先级继承等高级技术(虽然 Go 标准库不直接提供,但可以自行实现或使用第三方库)。
总结
"饥饿" 是并发系统中一个经典的问题,它描述了某个任务因长期得不到必要的资源而无法推进的状态。虽然 Go 的 GMP 模型通过其高效的调度算法在很大程度上缓解了这个问题,但作为开发者,在编写并发程序时,仍然需要时刻注意锁的使用、任务的拆分和 Goroutine 的生命周期管理,以编写出健壮、无 "饥饿" 的高质量代码。