与AI纸上谈兵-Go中I/O密集型需要设置P超过CPU核心数吗?
GO的GMP
在 Go 语言中,GMP 是 Go 运行时调度器的核心模型,用于高效地管理并发任务(goroutine)的执行。GMP 分别代表:
- G(Goroutine):Go 中的轻量级线程,表示一个独立的任务或协程。
- M(Machine):操作系统线程,Go 运行时通过 M 来执行 G。
- P(Processor):逻辑处理器,负责调度 G 到 M 上运行。
GMP 模型的设计目的是为了高效利用多核 CPU,同时隐藏底层线程管理的复杂性,提供更高效的并发支持。
GMP 模型的具体含义
G(Goroutine)
- Goroutine 是 Go 的核心并发单元,类似于协程。
- 每个 G 包含任务的执行上下文(如栈、程序计数器等)和调度信息。
- G 是非常轻量级的,创建和销毁的开销远小于操作系统线程。
M(Machine)
- M 是操作系统线程的抽象,负责执行 G。
- M 直接与操作系统线程绑定,Go 运行时会动态创建或销毁 M,以适应程序的并发需求。
- 一个 M 在任意时刻只能绑定一个 P,且只能执行绑定的 P 中的 G。
P(Processor)
- P 是逻辑处理器,负责调度 G 到 M 上运行。
- P 的数量由
GOMAXPROCS
决定,表示程序可以并行运行的最大线程数。 - P 维护一个本地的 G 队列,存储待执行的 Goroutine。
- 如果 P 的 G 队列为空,它可以从其他 P 的队列中窃取任务(work stealing)。
GMP 的工作流程
G 的创建
- 当程序创建一个新的 Goroutine 时,G 会被放入某个 P 的本地队列中,等待被调度执行。
P 调度 G
- P 从自己的本地队列中取出 G,并将其分配给绑定的 M 执行。
- 如果本地队列为空,P 会尝试从其他 P 的队列中窃取任务。
M 执行 G
- M 负责实际执行 G 的代码。
- 如果 G 阻塞(如 I/O 操作),M 会将当前 G 挂起,并尝试执行其他 G。
动态调整
- 如果 G 的数量远超 M 的数量,Go 运行时会动态创建新的 M。
- 如果某些 M 长时间空闲,Go 运行时会销毁这些 M,以节省资源。
GMP 模型的优点
高效的并发支持
- Goroutine 是轻量级的,创建和切换的开销远小于操作系统线程。
- GMP 模型通过逻辑处理器(P)和任务窃取机制,充分利用多核 CPU 的性能。
自动调度
- Go 运行时自动管理 Goroutine 的调度,开发者无需手动管理线程。
阻塞操作优化
- 当 Goroutine 阻塞时,Go 运行时会将其挂起,并调度其他 Goroutine 执行,避免线程资源浪费。
In the Go GMP model, does an IO-intensive application need to set the number of P greater than the number of CPU cores?
No, an I/O-intensive application in Go’s GMP (Goroutine, Machine, Processor) model generally does not need to set GOMAXPROCS
(the number of P’s) greater than the number of CPU cores. In fact, doing so can often be detrimental to performance. Here’s why:
- Concurrency vs. Parallelism: Go’s strength lies in concurrency, not necessarily parallelism. Concurrency means handling multiple tasks at the same time, while parallelism means executing multiple tasks simultaneously. I/O-bound operations spend most of their time waiting for external resources (disk, network, etc.). During this waiting time, the OS puts the goroutine to sleep. Another goroutine can then run on the same OS thread (and thus, the same P). Increasing the number of P’s won’t make the I/O faster; it just adds overhead for context switching between P’s.
- OS Thread Management: The P’s are mapped to OS threads. Having more P’s than CPU cores means the OS scheduler will be constantly switching between threads, leading to context switching overhead.() This overhead can outweigh any potential gains, especially in I/O-bound applications where the CPU is not the bottleneck.
- Blocking System Calls: When a goroutine makes a blocking system call (like reading from a file or network socket), the associated OS thread is blocked. However, the Go runtime uses a technique called “network poller” to handle network I/O efficiently. For file I/O, if the runtime detects a long blocking operation, it can detach the M (OS thread) from the P, allowing other goroutines to run on that P. When the I/O operation completes, the M is reattached. This mechanism minimizes blocking and context switching.
I/O时会发生什么?
网络 I/O: Go runtime 使用非阻塞的 I/O 模型和 epoll/kqueue 等操作系统提供的 I/O 多路复用机制(network poller)。当一个 G 发起网络 I/O 操作时,通常不会阻塞 M。而是将 I/O 请求交给操作系统,然后该 G 会被放入网络轮询器(network poller)中等待 I/O 事件。P 可以继续调度其他 G 在 M 上执行。当 I/O 事件发生时,runtime 会收到通知,并将相应的 G 重新放回 P 的 runqueue 中,等待调度。
文件 I/O (以及其他可能长时间阻塞的系统调用): 对于文件 I/O,如果操作是阻塞的(例如读取大文件),则情况会略有不同。在这种情况下:
- 如果 runtime 判定此 I/O 操作会长时间阻塞,会将 M 从 P 上解绑 。这个过程叫做
sysmon
会检测,当一个 M 阻塞时间过长,sysmon会把这个M和P分离。 - 此时,P 就变成空闲状态了,它可以寻找其他的 M 来绑定,或者创建新的 M。这样,其他的 G 就有机会在这个 P 上继续执行,不会因为某个 G 的文件 I/O 操作而导致整个 P 阻塞。
- 当 I/O 操作完成时,被解绑的 M 会尝试重新获取一个 P。如果此时有空闲的 P,则直接绑定;如果没有空闲的 P,则 M 会进入一个空闲 M 列表等待 P。
更多解绑细节
当 sysmon
将 M 和 P 解绑后,P 不会立即创建新的 M。P 的行为如下:
- 检查本地 runqueue: P 首先会检查其本地的 runqueue (runq) 中是否还有待执行的 G。如果 runqueue 不为空,P 会选择一个 G,并尝试找到一个 空闲的 M 来执行该 G。
- 寻找空闲 M: P 会首先查看是否有空闲的 M 列表 (idle M list)。如果存在空闲的 M,P 会直接与该 M 绑定,然后将 runqueue 中的 G 调度到该 M 上执行。
- 创建新的 M(如果需要): 如果没有空闲的 M 可用,且 P 的 runqueue 不为空,P 才会 创建一个新的 M,并与该 M 绑定,然后将 runqueue 中的 G 调度到该 M 上执行。
- 窃取 (work stealing): 如果 P 的 runqueue 也为空,P 会尝试从其他 P 的 runqueue 中 窃取 一部分 G。这是 Go 调度器实现负载均衡的重要机制。如果窃取成功,P 就会有待执行的 G,并按照上述步骤寻找或创建 M 来执行。
- 进入休眠 (spinning): 如果 P 的 runqueue 为空,且无法从其他 P 窃取到 G,P 会进入休眠状态 (spinning)。此时 P 不会消耗 CPU 资源,直到有新的 G 需要执行时才会被唤醒。
还有一个细节
sysmon是发现它是文件i/o所以分离M和P。还是因为他syscall时间太长所以分离。注意这两个的区别,一个是提前就发现了,一个是超时了才发现。
- 不是提前发现是文件 I/O
Go runtime 并不会在 G 执行系统调用 之前 就判断这是不是文件 I/O,然后采取特殊处理。实际上,runtime 对所有类型的阻塞系统调用都采用相同的处理方式,包括文件 I/O、网络 I/O(在某些情况下,虽然通常是异步的,但也可能发生阻塞)、以及其他用户自定义的阻塞系统调用。
这意味着:
- runtime 没有维护一个“文件 I/O 特殊处理”的列表或逻辑。
- runtime 不会区分系统调用的类型。
- 是 syscall 时间太长才分离
sysmon
的核心工作是 监控 ,而不是 预判 。它通过周期性地检查所有 M 的状态来判断是否有 M 阻塞在系统调用上的时间过长。这个“过长”的阈值通常是 10ms。
具体流程如下:
- G 执行一个系统调用。
- runtime 将该 G 的状态标记为
_Gsyscall
。 - 该 G 所在的 M 进入阻塞状态,等待系统调用完成。
sysmon
周期性地检查所有 M 的状态。- 如果
sysmon
发现某个 M 的状态为_Gsyscall
,并且持续时间超过阈值(10ms),它就认为该 M 正在执行长时间阻塞的系统调用。 sysmon
将该 M 与其绑定的 P 解绑。
因此,分离 M 和 P 的 根本原因是超时 ,而不是提前知道是哪种类型的系统调用。
参考
Google Gemini2.0
与AI纸上谈兵-Go中I/O密集型需要设置P超过CPU核心数吗?
https://cl0und.github.io/2025/01/04/与AI纸上谈兵-Go中I-O密集型需要设置P超过CPU核心数吗?/