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 来执行。
- 致命缺陷:
- 激烈的锁竞争:所有 M 争抢全局队列的锁(Global Mutex),导致性能大幅下降。
- 局部性差:G 在不同的 M 之间跳来跳去,无法利用 CPU 缓存。
- 系统调用开销: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 闲着也是闲着,怎么办?
策略:
- P2 发现本地队列空了。
- 去全局队列看一眼,空的(netpoll io也为空)。
- 偷 P2 会随机挑选一个 P1,从 P1 的本地队列里偷走一半的 G 放到自己的队列里执行。
意义:实现了负载均衡,充分利用多核 CPU。
3.3 系统调用与 Handoff (分离)
场景:M1 正在执行 G1,G1 调用了文件读取(Syscall),导致 M1 陷入内核态阻塞。
策略:
- 分离:P1 发现 M1 要阻塞很久,就会把 M1 踢开(Handoff)。
- 接管:P1 会寻找一个空闲的 M2(如果没有就新建一个 M),把 P1 绑定到 M2 上。
- 继续:M2 继续执行 P1 队列里剩下的 G。
- 归队:当 M1 的系统调用结束后,它会尝试找一个空闲的 P。找不到就把 G1 扔到全局队列,自己去sleep。
3.4 抢占式调度 (Preemption)
在 Go 1.14 之前,死循环的 G 会霸占 CPU。现在的调度器是基于信号的异步抢占。
- 监控:后台有一个
sysmon线程(不绑定 P)。 - 触发:如果发现某个 G 运行时间超过 10ms,
sysmon会向该 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 | func schedule() { |
4.3 网络轮询器 (Netpoller) —— Go 网络高并发的核心
当 G 进行网络 IO(如 http.Get)时,M 不会阻塞。
- G 挂起:G 状态变为
Waiting,并将 socket 文件描述符注册到 Netpoller(底层是 epoll/kqueue)。 - M 释放:M 此时手里没有 G 了,它会立即再次调用
schedule()寻找下一个 G。 - 唤醒:当网络数据到达,epoll 通知 Runtime,Netpoller 会将对应的 G 标记为
Runnable并插入到某个 P 的队列中。
总结:这就是为什么 Go 可以用很少的线程处理数万个并发连接,因为网络等待完全由 epoll 接管,不占用线程资源。
第五部分:知识点总结
如果面试官问“请讲讲 GMP 模型”:
- 背景:为了解决传统线程重、切换慢的问题,Go 引入了 Goroutine。为了解决早期 GM 模型全局锁竞争严重的问题,引入了 P。
- 架构:
- G:用户任务。
- M:内核线程(执行者)。
- P:逻辑处理器(带本地队列),解耦了 G 和 M。
- 调度策略:
- Work Stealing:P 闲时偷任务,负载均衡。
- Handoff:M 阻塞(Syscall)时释放 P,避免资源浪费。
- Preemption:基于信号的抢占,防止长任务卡死,时间片约 10ms。
- 底层原理:
- g0:负责运行调度代码的特殊栈。
- Netpoller:网络 IO 走 epoll,不阻塞 M,这是 Go 高并发的基石。