问题分析
面试官考察的是对 Go 语言并发模型核心机制的理解。Go 语言之所以能够轻松支持百万级并发,很大程度上归功于其高效的调度器(Scheduler)和 GMP 模型。你需要清晰地解释 G、M、P 分别代表什么,它们之间的关系,以及调度器是如何通过 Work Stealing(工作窃取)和 Hand Off(移交)机制来平衡负载、减少线程切换开销的。
核心解答
口语回答
“Go 语言的调度器采用了 GMP 模型。其中 G 代表 Goroutine,也就是协程,它包含了栈、指令指针等信息;M 代表 Machine,是内核线程,负责执行 G;P 代表 Processor,是处理器,它维护了一个本地队列,存放待执行的 G,并提供了 M 执行 G 所需的上下文资源。
调度的核心思想是复用线程。P 的数量由 GOMAXPROCS 决定,通常等于 CPU 核数。M 必须绑定 P 才能执行 G。当一个 M 阻塞(比如系统调用)时,P 会与 M 分离(Hand Off),去寻找空闲的 M 或创建新 M 继续执行队列中的 G,从而避免线程阻塞导致整个 P 闲置。
调度策略上,Go 采用了Work Stealing机制:当 P 的本地队列为空时,它会尝试从全局队列或其他 P 的本地队列‘偷’一半 G 来执行,实现了负载均衡。此外,Go 1.14 引入了基于信号的抢占式调度,解决了长循环导致其他 G 饿死的问题。”
核心结论
- GMP 模型:
- G (Goroutine): 协程,用户级轻量级线程。
- M (Machine): 内核线程,执行计算的实体。
- P (Processor): 处理器,管理 G 队列,解耦 G 和 M。
- 调度策略:
- 复用线程:避免频繁创建/销毁线程(Work Stealing, Hand Off)。
- 利用并行:
GOMAXPROCS设置 P 的数量,利用多核 CPU。 - 抢占:基于信号的抢占,防止长任务饿死其他 G。
- 全局队列:平衡负载,防止 G 饥饿。
详细解析
GMP 模型详解
Go 调度器的核心是 GMP 模型,它将 Goroutine (G) 映射到内核线程 (M) 上执行,中间引入了 Processor (P) 作为中间层。
- G (Goroutine):
- Go 语言中的协程,轻量级线程。
- 包含栈、指令指针 (IP)、调度信息等。
- 状态:
_Gwaiting(等待中)、_Grunnable(可运行)、_Grunning(运行中)、_Gsyscall(系统调用中)、_Gdead(已死亡)。
- M (Machine):
- 操作系统内核线程。
- 负责执行 G 的代码。
- M 需要绑定 P 才能执行 G(除非在执行系统调用或 GC 辅助工作)。
- M 的数量可以远大于 P,但同时执行 Go 代码的 M 数量最多只有
GOMAXPROCS个。
- P (Processor):
- 逻辑处理器,代表执行 G 所需的上下文资源(Context)。
- 维护了一个本地运行队列 (Local Run Queue),存放待执行的 G。
- P 的数量由
GOMAXPROCS环境变量决定,默认为 CPU 核数。 - P 的引入解耦了 G 和 M,使得 M 可以复用,并且实现了 Work Stealing。
调度器设计策略
Go 调度器旨在解决传统多线程模型的缺陷(高内存占用、高上下文切换开销),其核心策略包括:
-
复用线程 (Reuse Threads):
-
利用并行 (Utilize Parallelism):
- 通过设置
GOMAXPROCS,Go 程序可以同时在多个 CPU 核上运行,充分利用多核优势。
- 通过设置
-
抢占 (Preemption):
- 协作式抢占 (旧版):依赖 G 主动让出 CPU(如函数调用时检查栈)。如果 G 执行死循环且无函数调用,会导致其他 G 饿死。
- 基于信号的抢占 (Go 1.14+):sysmon 线程会向运行时间过长的 M 发送信号(SIGURG),M 收到信号后会中断当前 G 的执行,保存上下文,将其放入全局队列,并调度下一个 G。
-
全局队列 (Global Queue):
- 除了 P 的本地队列,还有一个全局运行队列。
- 当 P 的本地队列满时,新创建的 G 会被放入全局队列。
- 为了防止全局队列中的 G 饿死,P 在调度循环中会以一定的概率(每 61 次调度)优先检查全局队列。
调度过程
调度器 schedule() 函数的大致逻辑如下:
- 检查全局队列:为了保证公平性,每经过 61 次调度,优先从全局队列获取 G。
- 检查本地队列:从当前 P 的本地队列获取 G。
- Work Stealing:如果本地队列为空,尝试从其他 P 的本地队列窃取 G。
- 检查网络轮询器 (Netpoller):如果还是没有 G,检查网络轮询器是否有就绪的 G(非阻塞 I/O)。
- 执行 G:找到 G 后,M 开始执行 G。
- G 结束/阻塞:G 执行完毕或阻塞后,M 再次进入调度循环。
特殊场景
系统调用 (Syscall)
- Raw Syscall (如 CGO):M 和 G 都会阻塞。P 会与 M 分离(Hand Off),寻找其他 M 执行 P 队列中的 G。
- Netpoller (网络 I/O):Go 对网络 I/O 进行了封装。当 G 进行网络读写时,会被放入 Netpoller 中等待,M 不会阻塞,而是继续执行其他 G。当网络数据就绪后,G 会被移回 P 的本地队列或全局队列。
阻塞 (Channel/Mutex)
当 G 因为 Channel 操作或 Mutex 锁阻塞时,G 会被挂起(状态变为 _Gwaiting),并从 P 的运行队列中移除。M 会继续执行队列中的下一个 G。当条件满足(如 Channel 可读/写,锁释放)时,G 会被唤醒并重新放入 P 的队列。
扩展知识
1. 为什么要有 P?
在 Go 1.0 时代,只有 G 和 M,调度器维护一个全局的 G 队列。所有 M 都从全局队列获取 G,这导致了严重的锁竞争(Global Lock Contention)。P 的引入主要就是为了解决这个问题:
- 本地队列:每个 P 都有自己的本地队列,M 获取 G 时大多只需要访问本地队列,无需加全局锁,大大减少了锁竞争。
- 数据局部性:G 在同一个 P 上执行,利用了 CPU 缓存。
2. GOMAXPROCS 的默认值
在 Go 1.5 之前,GOMAXPROCS 默认值为 1。从 Go 1.5 开始,默认值为 CPU 的核数。这意味着 Go 程序默认就是并行的。
3. 调度器的可视化
可以使用 go tool trace 工具来可视化查看调度器的行为,分析 G 的创建、阻塞、调度延迟等信息,是排查性能问题的利器。
推荐阅读
- Scalable Go Scheduler Design Doc: Dmitry Vyukov 撰写的调度器设计文档,是理解 GMP 模型起源和设计决策的权威资料。
- Go 语言设计与实现 - 调度器: Draveness 的深度解析文章,图文并茂,非常适合中文读者。
- Scheduling In Go : Part I - OS Scheduler: Ardan Labs 的 William Kennedy 撰写的系列文章(共三篇),深入浅出地讲解了 OS 调度器和 Go 调度器。
- The Go scheduler: Daniel Morsing 的经典文章,虽然较早,但对基础概念的解释非常清晰。