面试官:能详细讲讲 Golang 的 GMP 调度模型吗?

面试回答

“好的。GMP 模型是 Go 语言用来实现高效并发的运行时调度机制。它主要由三个核心组件构成:G、M 和 P。

G 代表 Goroutine,也就是我们代码里开启的协程,它包含了执行任务需要的栈和指令指针。相比于操作系统的线程,G 非常轻量,初始栈只有 2KB。 M 代表 Machine,也就是操作系统的内核线程,它是真正干活的实体。 P 代表 Processor,也就是逻辑处理器,它里面维护了运行 G 所需要的资源,最重要的是它有一个本地的可运行 G 队列。P 的数量通常由 GOMAXPROCS 决定,代表了程序真正的并行度。

它们是怎么协作的呢?简单来说,M 必须要绑定一个 P 才能执行 G。当程序创建一个新的 G 时,会优先把它放进当前 P 的本地队列里。M 绑定 P 之后,就会不断地从这个本地队列里取出 G 来运行。如果本地队列空了,M 还会尝试从全局队列,甚至是其他 P 的本地队列里去‘偷’ G 来执行,这就是 Work Stealing 机制。

如果遇到系统调用导致 M 阻塞了,M 就会和 P 解绑,带着那个阻塞的 G 一起休眠。而 P 会立刻去寻找一个新的 M 来接管自己队列里的其他 G,确保任务不会被饿死,这被称为 Handoff 机制。通过这种 G、M、P 解耦的设计,Go 极大地降低了线程切换的开销,充分利用了多核 CPU 的性能。”

系统讲解

核心机制解析

G (Goroutine)

代表一个用户态的并发任务,包含栈、指令指针(PC)以及其他调度状态。相比操作系统线程,G 的初始栈极小(通常 2KB),创建和切换成本极低。

M (Machine)

代表操作系统的内核线程。M 负责执行 G 的代码。M 只有绑定了 P 才能执行 G,M 的数量默认最大为 10000,但通常活跃的 M 数量与 P 的数量相近。

P (Processor)

代表逻辑处理器,包含了运行 G 所需的资源和本地可运行 G 队列(最多存放 256 个 G)。P 的数量通常由 GOMAXPROCS 决定,代表了真正的并发度。所有的 P 都在程序启动时创建,并保存在一个数组中。

整体运转流程

  1. 创建与入队:通过 go func() 创建 G 时,优先放入当前 P 的本地队列。如果本地队列已满(超过 256),则会将本地队列的一半 G 和新创建的 G 一起放入全局队列。
  2. 调度循环:M 绑定 P 后,进入调度循环,不断获取 G 并执行。获取 G 的顺序:
    • 每执行 61 次调度,优先从全局队列获取 G(防止全局队列饿死)。
    • 从 P 的本地队列获取 G。
    • 从全局队列获取 G。
    • 从网络轮询器(Netpoller)获取就绪的 G。
    • 从其他 P 的本地队列“偷取 (Work Stealing)”一半的 G 来执行。
  3. 执行与切换:M 执行 G 的代码,直到 G 运行结束、主动让出(runtime.Gosched())或发生阻塞。

关键逻辑剖析

阻塞与系统调用 (Syscall)

当 G 执行阻塞的系统调用时,M 会和 P 解绑,M 和 G 一起进入阻塞状态。P 会寻找新的 M(或唤醒休眠的 M)来接管自己的本地队列,从而保证其他 G 不被饿死。系统调用结束后,G 会尝试重新获取 P,获取不到则放入全局队列,M 进入休眠。

网络 I/O 阻塞

Go 并没有让 M 阻塞在网络 I/O 上,而是使用了 Netpoller(基于 epoll/kqueue)。当 G 发起网络 I/O 时,G 会被挂起并放入 Netpoller,M 继续执行其他 G。当 I/O 就绪时,Netpoller 会唤醒对应的 G 重新放入队列。

亮点与深度

演进历史

Go 1.0 时代只有 GM 模型,存在单一全局锁、M 频繁休眠唤醒、局部性差等严重的性能问题。Go 1.1 引入了 P 形成 GMP 模型,通过引入本地队列和 Work Stealing 机制,极大地减少了全局锁的竞争,彻底解决了这些痛点。

抢占式调度

  • 协作式抢占:早期 Go 版本依赖 G 主动让出 CPU(如函数调用时检查栈溢出)。如果一个 G 执行死循环且没有函数调用,会导致所在 P 上的其他 G 饿死。
  • 基于信号的异步抢占:Go 1.14 引入了基于 SIGURG 信号的抢占式调度。后台的 sysmon 监控线程如果发现某个 G 运行时间过长(超过 10ms),会向运行该 G 的 M 发送信号,强制其中断并让出 CPU,解决了密集计算导致的饿死问题。