GMP模型

Go 语言实现高并发支持的核心竞争力之一就是原生支持高并发

不同于 Java 或 C++ 依赖操作系统线程(Kernel Thread)的重量级并发,Go 引入了轻量级的协程(Goroutine)。但成千上万个 Goroutine 如何在有限的 CPU 核心上高效运行?这就归功于 Go Runtime 中精妙的调度器设计 —— GMP 模型

第一部分:背景

1.1 线程 vs 协程

  • OS 线程 (Kernel Thread)
    • :创建时默认栈大小约 2MB。
    • :上下文切换涉及内核态(Kernel Mode)与用户态(User Mode)的切换,开销大。
  • 协程 (Goroutine)
    • :初始栈大小仅 2KB,且可动态伸缩。
    • :切换完全在用户态进行,只需保存几个寄存器,开销极低。

1.2 早期的 GM 模型

最早的 Go 调度器只有 G(Goroutine)和 M(Machine/Thread)。

  • 机制:所有的 M 都从一个全局队列中获取 G 来执行。
  • 致命缺陷
    1. 激烈的锁竞争:所有 M 争抢全局队列的锁(Global Mutex),导致性能大幅下降。
    2. 局部性差:G 在不同的 M 之间跳来跳去,无法利用 CPU 缓存。
    3. 系统调用开销:M 经常因为系统调用阻塞和唤醒,带来额外的开销。

为了解决这些问题,Dmitry Vyukov 在 Go 1.1 引入了 P (Processor),实现了现在的 GMP 模型。


第二部分:GMP 模型架构

2.1 定义

G (Goroutine)

  • 角色:待执行的任务。
  • 内容:包含了栈信息、指令指针(IP)、当前状态等。
  • 特点:创建和销毁几乎没有开销。

M (Machine)

  • 角色:操作系统的内核线程,是真正“干活”的苦力。
  • 职责:M 必须绑定一个 P,才能执行 P 本地队列中的 G。
  • 数量
    • Go 运行时默认最大 M 数量为 10000(为了防止阻塞耗尽资源)。
    • 当 M 阻塞在系统调用中时,会创建新的 M。

P (Processor)

  • 角色逻辑处理器,也就是 G 和 M 之间的“中介”和“资源包”。
  • 核心作用:它维护了一个本地队列 (Local Run Queue)
  • 数量
    • 由环境变量 $GOMAXPROCS 决定,默认等于 CPU 核心数。
    • P 的数量决定了系统的并行度

2.2 比喻

想象一个建筑工地:

  • G (砖块):需要被处理的任务。
  • P (手推车):存放砖块的容器(本地缓存),用来减少去总仓库(全局队列)拿砖块的排队时间。
  • M (工人):负责搬砖。工人必须推着手推车(绑定 P)才能搬砖(执行 G)。

第三部分:调度器核心策略

GMP 模型的高效,不仅仅在于结构,更在于灵活的调度策略。

3.1 队列轮转与局部性

  • 每个 P 维护一个本地队列(数组结构,无锁,速度快)。
  • M 绑定 P 后,优先从 P 的本地队列取 G 执行。
  • 机制:G 执行完或时间片用完后,会被放回队列尾部,实现轮转。
  • 防饥饿:为了防止全局队列中的 G 没人理,调度器每调度 61 次,就会强制去全局队列拿一个 G。

3.2 工作窃取 (Work Stealing)

场景:如果 P1 的任务非常多,而 P2 的任务做完了,P2 闲着也是闲着,怎么办?
策略

  1. P2 发现本地队列空了。
  2. 去全局队列看一眼,空的(netpoll io也为空)。
  3. P2 会随机挑选一个 P1,从 P1 的本地队列里偷走一半的 G 放到自己的队列里执行。
    意义:实现了负载均衡,充分利用多核 CPU。

3.3 系统调用与 Handoff (分离)

场景:M1 正在执行 G1,G1 调用了文件读取(Syscall),导致 M1 陷入内核态阻塞。
策略

  1. 分离:P1 发现 M1 要阻塞很久,就会把 M1 踢开(Handoff)。
  2. 接管:P1 会寻找一个空闲的 M2(如果没有就新建一个 M),把 P1 绑定到 M2 上。
  3. 继续:M2 继续执行 P1 队列里剩下的 G。
  4. 归队:当 M1 的系统调用结束后,它会尝试找一个空闲的 P。找不到就把 G1 扔到全局队列,自己去sleep。

3.4 抢占式调度 (Preemption)

在 Go 1.14 之前,死循环的 G 会霸占 CPU。现在的调度器是基于信号的异步抢占

  • 监控:后台有一个 sysmon 线程(不绑定 P)。
  • 触发:如果发现某个 G 运行时间超过 10mssysmon 会向该 M 发送信号(SIGURG)。
  • 中断:M 收到信号后,在中断处理中将当前 G 挂起,放回队列尾部,让出 CPU。

第四部分:Runtime 源码

4.1 核心结构体 (src/runtime/runtime2.go)

struct g

其中,sched 字段它保存了 G 的上下文(SP 栈指针, PC 程序计数器)。
协程切换本质上就是将 CPU 寄存器保存到 g.sched 并恢复新 G 的 sched

struct m

  • g0非常重要。每个 M 都有一个 g0 协程,它的栈是系统栈(System Stack,较大)。
  • 作用:所有调度逻辑(如 schedule() 函数)、垃圾回收扫描、栈扩容等代码,都是在 g0 上运行的。
  • 切换路径G_UserA -> g0 -> G_UserB。用户 G 不能直接切换到另一个用户 G。

struct p

  • runq:本地队列,本质是一个 256 长度的环形数组。
  • runnext:一个特殊的指针(最高执行优先级)。当 GA 创建 GB 时,GB 会被优先放在 runnext 位置,而不是队尾,同时GA则会放到队尾去。这是为了利用 CPU 缓存的局部性。

4.2 调度循环 schedule()

调度器其实就是一个无限循环,定义在 runtime/proc.go 中。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
func schedule() {
_g_ := getg() // 获取当前的 m.g0

var gp *g
var inheritTime bool

// 1. Check GC: 如果正在 GC,可能需要暂停
// 2. Check Global: 每 61 次去全局队列拿
// 3. Check Local: 从 P.runq 本地队列拿
// 4. Check Steal: 上述都没有,进入 findrunnable() 阻塞查找 (偷、查网路)

if gp != nil {
execute(gp, inheritTime) // 执行 G
}
}

4.3 网络轮询器 (Netpoller) —— Go 网络高并发的核心

当 G 进行网络 IO(如 http.Get)时,M 不会阻塞

  1. G 挂起:G 状态变为 Waiting,并将 socket 文件描述符注册到 Netpoller(底层是 epoll/kqueue)。
  2. M 释放:M 此时手里没有 G 了,它会立即再次调用 schedule() 寻找下一个 G。
  3. 唤醒:当网络数据到达,epoll 通知 Runtime,Netpoller 会将对应的 G 标记为 Runnable 并插入到某个 P 的队列中。

总结:这就是为什么 Go 可以用很少的线程处理数万个并发连接,因为网络等待完全由 epoll 接管,不占用线程资源。


第五部分:知识点总结

如果面试官问“请讲讲 GMP 模型”:

  1. 背景:为了解决传统线程重、切换慢的问题,Go 引入了 Goroutine。为了解决早期 GM 模型全局锁竞争严重的问题,引入了 P。
  2. 架构
    • G:用户任务。
    • M:内核线程(执行者)。
    • P:逻辑处理器(带本地队列),解耦了 G 和 M。
  3. 调度策略
    • Work Stealing:P 闲时偷任务,负载均衡。
    • Handoff:M 阻塞(Syscall)时释放 P,避免资源浪费。
    • Preemption:基于信号的抢占,防止长任务卡死,时间片约 10ms。
  4. 底层原理
    • g0:负责运行调度代码的特殊栈。
    • Netpoller:网络 IO 走 epoll,不阻塞 M,这是 Go 高并发的基石。