Go 语言的 GMP 调度模型
Go 语言之所以能够轻松支持百万级并发,其核心就在于它的调度器模型——GMP 模型。
GMP 是 Go 运行时(Runtime)调度层面的三个核心组件的缩写:G (Goroutine)、M (Machine)、P (Processor)。
下面我将从概念、模型演变、核心策略和工作流程四个方面深入浅出地为你讲解。
1. 核心组件介绍 (G、M、P)
你可以把 GMP 模型想象成一个建筑工地:
G (Goroutine - 协程/任务):
- 概念:Go 语言中的协程,用户级线程。
- 特点:非常轻量(初始栈仅 2KB),由 Go 运行时管理,而非操作系统。
- 比喻:“砖头”或“任务单”。这是具体要干的活。
M (Machine - 内核线程):
- 概念:操作系统内核线程(OS Thread)。
- 特点:它是真正执行计算资源的载体。M 必须绑定 P 才能执行 G。
- 比喻:“建筑工人”。只有工人才能真正动手搬砖。
P (Processor - 处理器/上下文):
- 概念:逻辑处理器,包含了运行 G 所需的资源(如本地队列、内存分配器等)。
- 特点:P 的数量决定了系统最大并行度,由
GOMAXPROCS决定。 - 作用:连接 G 和 M 的桥梁。M 只有拿到 P,才能去执行 G。
- 比喻:“工卡”或“施工队”。工人(M)必须拿到工卡(P)才能进场干活,工卡里夹着一堆任务单(G)。
2. 为什么要引入 P?(GM 模型 vs GMP 模型)
在 Go 1.1 之前,使用的是 GM 模型,没有 P。
- GM 的问题:所有的 M 都去同一个全局队列里抢 G 执行。为了安全,必须加一把全局大锁。
- 后果:锁竞争极其激烈,性能很差;M 的缓存局部性差。
引入 P 之后:
P 拥有一个本地队列 (Local Run Queue)。
- M 拿到 P 后,优先从 P 的本地队列取 G 执行,不需要加锁(因为是本地的)。
- 只有本地没货了,才去全局队列或偷别人的,大大减少了锁竞争。
3. GMP 的核心调度策略
Go 调度器为了让“工人”不闲着,且活干得快,设计了以下几个关键机制:
A. 复用线程 (Work Stealing & Hand Off)
避免频繁创建和销毁操作系统线程(M),而是复用它们。
Work Stealing (工作窃取机制):
- 当一个 M 绑定的 P 的本地队列空了,M 不会休息。
- 它会先去全局队列看有没有 G。
- 如果全局也没有,它会去其他 P 的本地队列“偷”一半的 G 过来执行。
- 目的:负载均衡,不让任何一个线程闲置。
Hand Off (握手/分离机制):
- 当 M 执行的 G 进行系统调用阻塞(如读写文件)时,M 会被阻塞住。
- 为了不让 P 闲着(P 里还有其他 G 等着跑),P 会与当前的 M 分离(Detach)。
- P 会去寻找一个新的 M(或新建一个 M)来继续执行 P 队列里剩下的 G。
- 当旧的 M 阻塞结束,它会尝试获取一个空闲的 P;如果拿不到,就把它的 G 放入全局队列,自己休眠。
- 目的:提高资源利用率,不让阻塞阻碍整体进度。
B. 抢占式调度 (Preemption)
- 问题:如果一个 G 写了个死循环
for {},一直占用 M 和 P 怎么办? - 解决:Go 有一个后台监控线程 sysmon。它会监控运行时间过长的 G(超过 10ms)。
- 动作:sysmon 会强行把这个 G 也就是“踢”出去,放到全局队列尾部,让其他 G 有机会执行。
- 注:Go 1.14 引入了基于信号的异步抢占,解决了死循环无法被抢占的问题。
C. 网络轮询器 (Netpoller)
- 当 G 进行网络 I/O(如 HTTP 请求)时,不会阻塞 M。
- G 会被扔给 Netpoller 等待网络数据,M 继续执行其他 G。
- 当网络数据到了,Netpoller 会把 G 放回 P 的队列中。
- 意义:这是 Go 写网络高并发服务极其高效的原因。
4. 完整的调度流程图解
假设一个 G (G1) 被创建:
放入队列:
G1优先放入当前 P 的本地队列。- 如果本地队列满了(256个),
G1会被放入全局队列(需要加锁)。
M 获取 G:
- M 绑定 P,从 P 的本地队列弹出一个 G 开始执行。
执行过程:
- 正常结束:G 执行完,M 销毁 G,继续取下一个。
- 发生阻塞 (Syscall):M 和 P 分离,P 找新 M。
- 发生网络 I/O:G 移交 Netpoller,M 继续干活。
- 本地空闲:M 发现本地没 G 了,去全局队列取;全局也没了,去偷别的 P 的 G。
5. 总结
GMP 模型的高效在于:
- M:N 模型:N 个协程(G)复用 M 个内核线程,既节省了线程创建开销,又利用了多核优势。
- P 的本地队列:消除了全局锁的性能瓶颈(无锁访问)。
- Work Stealing:实现了自动负载均衡。
- Netpoller:将网络阻塞转化为非阻塞,完美契合高并发 I/O 场景。
这就是 Go 语言“高并发、高性能”背后的秘密武器。
右滑查看面试常问