vault backup: 2026-06-07 12:14:39
This commit is contained in:
+206
-101
@@ -1,118 +1,223 @@
|
||||
---
|
||||
tags:
|
||||
- Go
|
||||
- golang
|
||||
- GMP
|
||||
- 面试题
|
||||
- GMP面试题
|
||||
tags: [go, golang, interview, gmp-questions]
|
||||
create time: 2026-06-07 14:30
|
||||
---
|
||||
|
||||
# GMP面试题
|
||||
# GMP 面试题 ⚙️
|
||||
|
||||
## 1. Go语言的GMP模型是什么?
|
||||
## 概述
|
||||
|
||||
GMP是Go运行时的核心调度模型
|
||||
本文件涵盖 Go GMP 调度模型的 10 道高频面试题,涉及 G/M/P 含义、调度策略、抢占机制、工作窃取等核心概念。GMP 是 Go 高并发的基石,深入理解它是面试中冲击高薪的关键。
|
||||
|
||||
**GMP含义**:G是goroutine协程;M是machine系统线程,真正干活的;P是processor,逻辑处理器,它是G和M之间的桥梁。它负责调度G
|
||||
## 关联笔记
|
||||
|
||||
调度逻辑是这样的,M必须绑定P才能执行G。每个P维护一个自己的本地G队列(长度256),M从P的本地队列取G执行。当本地队列空时,M会按优先级从**全局队列、网络轮询器、其他P队列**中窃取goroutine,这是work-stealing机制。
|
||||
- [[hzh/GolangStar/Go语言进阶/Goroutine]] — Goroutine 基础
|
||||
- [[hzh/GolangStar/Go语言原理/gmp调度原理]] — GMP 源码级详解
|
||||
- [[hzh/GolangStar/Go面试题库/内存管理面试题]] — 内存与调度的关系
|
||||
|
||||
就是这个模型让Go能在少量线程上调度海量goroutine,是Go高并发的基础。
|
||||
## 正文
|
||||
|
||||

|
||||
### Q1:Go 语言的 GMP 模型是什么? 🟡中等
|
||||
|
||||
## 2. 什么是Go scheduler
|
||||
> [!question] ❓ 思考一下
|
||||
> 如果让你设计一个能调度百万级协程的系统,你会怎么组织 M(系统线程)和 G(协程)的关系?直接 M-G 一对一行吗?
|
||||
|
||||
Go scheduler就是Go运行时的**协程调度器**,负责在系统线程上调度执行goroutine。它 是 Go runtime 的一部分,它内嵌在 Go 程序里,和 Go 程序一起运行。它的主要工作是决定哪个goroutine在哪个线程上运行,以及何时进行上下文切换。scheduler的核心是`schedule()`函数,它在无限循环中寻找可运行的goroutine。当找到后通过`execute()`函数切换到goroutine执行,goroutine主动让出或被抢占时再回到调度循环。
|
||||
## 参考答案
|
||||
|
||||
## 3. Go语言在进行goroutine调度的时候,调度策略是怎样的?
|
||||
GMP 是 Go 运行时的**核心调度模型**:
|
||||
|
||||
Go语言采用的是抢占式调度策略。Go 会启动一个线程,一直运行着“sysmon”函数,sysmon 运行在 M上,且不需要P。当 sysmon 发现 M 已运行同一个 G(Goroutine)10ms 以上时,它会将该 G 的内部参数 `preempt` 设置为 true,表示需要被抢占,让出CPU了。只是在Go 1.14之前和Go 1.14之后有所不同
|
||||
| 组件 | 含义 | 角色 |
|
||||
|------|------|------|
|
||||
| **G (Goroutine)** | 协程 | 干活的工作单元 |
|
||||
| **M (Machine)** | 系统线程 | 真正执行代码的"手" |
|
||||
| **P (Processor)** | 逻辑处理器 | G 和 M 之间的"桥梁",负责调度 G |
|
||||
|
||||
**Go 1.14之前**:调度策略是“协作式”抢占调度,这种调度方式主要是通过函数调用来实现的,在编译期,编译器会在几乎所有的函数调用的入口处,插入一小段检查代码。这段代码会检查当前goroutine是否已经被标记为需要被抢占。如果是,当 G 进行函数调用时,G 会检查自己的 `preempt` 标志,如果它为 true,则它将自己与 M 分离并推入goroutine的全局队列,抢占完成。但这种模式有个明显的缺陷:如果一个goroutine执行了一个不包含任何函数调用的**超大循环**,那么调度器的“抢占”标记就永远得不到检查,这个goroutine就会一直霸占着M,导致同一个P队列里的其他G全都没机会执行,造成**调度延迟**。
|
||||
**调度逻辑:** M 必须绑定 P 才能执行 G。每个 P 维护自己的本地队列(最多 256 个 G),M 从 P 的本地队列取 G 执行。当本地队列空时,M 按优先级从全局队列、网络轮询器或其他 P 中窃取 goroutine(work-stealing)。
|
||||
|
||||
**Go 1.14之后**:调度策略**基于信号的异步抢占**机制,sysmon 会检测到运行了 10ms 以上的 G(goroutine)。然后,sysmon 向运行 G 的 M发送信号(SIGURG)。Go 的信号处理程序会调用M上的一个叫作 gsignal 的 goroutine 来处理该信号,并使其检查该信号。gsignal 看到抢占信号,停止正在运行的 G。
|
||||
|
||||
## 4. 发生调度的时机有哪些?
|
||||
|
||||
* 等待读取或写入未缓冲的通道
|
||||
|
||||
* 由于 time.Sleep() 而等待
|
||||
|
||||
* 等待互斥量释放
|
||||
|
||||
* 发生系统调用
|
||||
|
||||
## 5. M寻找可运行G的过程是怎样的?
|
||||
|
||||
**M会优先检查本地队列(LRQ)**:从当前P的LRQ里`runqget`一个G。(无锁CAS),如果本地队列没有可运行G,**再次检查全局队列(GRQ)**去全局队列里`globrunqget`找。(需要加锁);如果还没有,就**检查网络轮询器(netpoll),**就去`netpoll`里看看有没有因为网络IO就绪的G。(非阻塞模式),依然没有获取到可运行G,则会**从别的P偷(steal work),这个偷的过程是**随机找一个别的P,从它的LRQ里偷一半的G过来。
|
||||
|
||||

|
||||
|
||||
## 6. GMP能不能去掉P层?会怎么样?
|
||||
|
||||
GMP中的P层理论上可以去掉,但会带来严重的性能问题。
|
||||
|
||||
**掉P的后果**:如果直接变成GM模型,所有M都需要从**全局队列**中获取goroutine,这就需要全局锁保护。在高并发场景下,大量M争抢同一把锁会造成严重的**锁竞争**,CPU大部分时间都浪费在等锁上,调度效率急剧下降。
|
||||
|
||||
**P层的价值**:P的存在实现了**无锁的本地调度**。每个P维护独立的本地队列,M绑定P后可以直接从本地队列取G执行,大部分情况下都不需要全局锁。只有本地队列空了才去偷取,这大大减少了锁竞争。
|
||||
|
||||
## 7. P和M在什么时候会被创建?
|
||||
|
||||
**P的创建时机**:P在调度器初始化时**一次性创建**。在`schedinit()`函数中会调用`procresize()`,根据`GOMAXPROCS`值创建对应数量的P对象,存储在全局的`allp`数组中。之后P的数量基本固定,只有在调用`runtime.GOMAXPROCS()`动态调整时才会重新分配P。
|
||||
|
||||
**M的创建时机**:M采用**按需创建**策略。初始只有m0存在,当出现以下情况时会创建新的M:
|
||||
|
||||
* 所有现有M都在执行阻塞的系统调用,但还有可运行的goroutine需要执行
|
||||
|
||||
* 通过`startm()`函数发现没有空闲M可以绑定P执行goroutine
|
||||
|
||||
* M的数量受`GOMAXTHREADS`限制,默认10000个
|
||||
|
||||
**创建流程**:新M通过`newm()`函数创建,它会调用`newosproc()`创建新的系统线程,并为这个M分配独立的g0。创建完成后,新M会进入`mstart()`开始调度循环。
|
||||
|
||||
## 8. m0是什么,有什么用
|
||||
|
||||
m0是在Go启动时创建的第一个M,m0对应程序启动时的主系统线程,它在Go程序的整个生命周期中都存在。与其他通过`runtime.newm()`动态创建的M不同,m0是在程序初始化阶段静态分配的,有专门的全局变量存储。
|
||||
|
||||
m0主要负责执行Go程序的**启动流程**,包括调度器初始化、内存管理器初始化、垃圾回收器设置等。它会创建并运行第一个用户goroutine来执行`main.main`函数。在程序运行期间,m0也参与正常的goroutine调度,和其他M没有本质区别。m0在程序退出时还负责处理清理工作,比如等待其他goroutine结束、执行defer函数等。
|
||||
|
||||

|
||||
|
||||
## 9. g0是一个怎样的协程,有什么用?
|
||||
|
||||
g0是一个特殊的goroutine,不是普通的用户协程,而是**调度协程**,每个M都有自己的g0。它使用系统线程的原始栈空间,而不是像普通goroutine那样使用可增长的分段栈。g0的栈大小通常是8KB,比普通goroutine的2KB初始栈要大。
|
||||
|
||||
**核心作用**:g0专门负责**执行调度逻辑**,包括goroutine的创建、销毁、调度决策等。当M需要进行调度时,会从当前运行的用户goroutine切换到g0上执行`schedule()`函数。g0还负责处理垃圾回收、栈扫描、信号处理等运行时操作。
|
||||
|
||||
**运行机制**:正常情况下M在用户goroutine上运行用户代码,当发生调度事件时(如goroutine阻塞、抢占、系统调用返回等),M会切换到g0执行调度器代码,选出下一个要运行的goroutine后再切换过去。
|
||||
|
||||
**为什么需要g0**:因为调度器代码不能在普通goroutine的栈上执行,那样会有栈空间冲突和递归调度的问题。g0提供了一个独立的执行环境,确保调度器能安全稳定地工作。
|
||||
|
||||
## 10. g0栈和用户栈是如何进行切换的?
|
||||
|
||||
g0和用户goroutine之间的栈切换,本质是**SP寄存器和栈指针的切换。**当用户goroutine需要调度时,通过`mcall()`函数切换到g0。这个过程会保存当前用户goroutine的PC、SP等寄存器到其gobuf中,然后将SP指向g0的栈,PC指向传入的调度函数。调度完成后,通过`gogo()`函数从g0切换回用户goroutine,恢复其保存的寄存器状态。
|
||||
|
||||
切换逻辑在汇编文件中实现,比如`runtime·mcall`和`runtime·gogo`。这些函数直接操作CPU寄存器,确保切换的原子性和高效性。切换过程中会更新g.sched字段记录goroutine状态。
|
||||
|
||||
**分析:**
|
||||
|
||||
goroutine的结构如下:
|
||||
|
||||
```go
|
||||
structG
|
||||
{
|
||||
uintptr stackguard; // 分段栈的可用空间下界
|
||||
uintptr stackbase; // 分段栈的栈基址
|
||||
Gobuf sched; //协程切换时,利用sched域来保存上下文
|
||||
uintptr stack0;
|
||||
FuncVal* fnstart; // goroutine运行的函数void* param; // 用于传递参数,睡眠时其它goroutine设置param,唤醒时此goroutine可以获取
|
||||
int16 status; // 状态 Gidle,Grunnable,Grunning,Gsyscall,Gwaiting,Gdead
|
||||
int64 goid; // goroutine的id号
|
||||
G* schedlink;
|
||||
M* m; // for debuggers, but offset not hard-coded
|
||||
M* lockedm; // G被锁定只能在这个m上运行
|
||||
uintptr gopc; // 创建这个goroutine的go表达式的pc...
|
||||
};
|
||||
```mermaid
|
||||
graph LR
|
||||
G1["G1"] --> P["P (逻辑处理器)"]
|
||||
G2["G2"] --> P
|
||||
G3["G3"] --> P
|
||||
G4["G4"] --> P
|
||||
|
||||
P --> M["M (系统线程)"]
|
||||
|
||||
style P fill:#FF9800,color:#fff
|
||||
style M fill:#2196F3,color:#fff
|
||||
```
|
||||
|
||||
> [!note] 📝 核心考点
|
||||
> GMP 的核心价值:**用少量 M(系统线程)调度海量 G(协程)**。P 层实现了无锁本地调度,避免了全局锁竞争。
|
||||
|
||||
---
|
||||
|
||||
### Q2:什么是 Go scheduler? 🟢简单
|
||||
|
||||
## 参考答案
|
||||
|
||||
Go scheduler 是 Go 运行时的**协程调度器**,内嵌在程序中一起运行。它的核心工作是决定哪个 goroutine 在哪个线程上运行,以及何时进行上下文切换。
|
||||
|
||||
核心函数 `schedule()` 在无限循环中寻找可运行的 goroutine,找到后通过 `execute()` 切换到 goroutine 执行。
|
||||
|
||||
> [!info] 🔗 延伸阅读
|
||||
> - [[hzh/GolangStar/Go语言原理/gmp调度原理]] — schedule() 源码分析
|
||||
|
||||
---
|
||||
|
||||
### Q3:Go 的 goroutine 抢占调度策略? 🟡中等
|
||||
|
||||
## 参考答案
|
||||
|
||||
Go 采用**抢占式调度**,由 sysmon 线程监控。不同版本的实现方式不同:
|
||||
|
||||
#### Go 1.14 之前:协作式抢占
|
||||
|
||||
- 编译器在函数调用入口插入检查代码
|
||||
- G 进行函数调用时检查 `preempt` 标志
|
||||
- **缺陷**:不含函数调用的超大循环不会被抢占,造成调度延迟
|
||||
|
||||
#### Go 1.14 及之后:基于信号的异步抢占
|
||||
|
||||
- sysmon 检测到运行超过 10ms 的 G
|
||||
- 向 M 发送 SIGURG 信号
|
||||
- gsignal goroutine 处理信号,停止正在运行的 G
|
||||
|
||||
> [!warning] ⚠️ 高频陷阱
|
||||
> Go 1.14 之前的协作式抢占无法处理"纯计算密集型且无函数调用的 goroutine",这是最常见的面试追问点。
|
||||
|
||||
---
|
||||
|
||||
### Q4:发生调度的时机有哪些? 🟢简单
|
||||
|
||||
## 参考答案
|
||||
|
||||
| 触发条件 | 说明 |
|
||||
|---------|------|
|
||||
| Channel 操作 | 等待读写未缓冲 channel |
|
||||
| time.Sleep() | 等待睡眠时间结束 |
|
||||
| Mutex 等待 | 获取互斥量失败 |
|
||||
| 系统调用 | 发起 syscall 进入内核态 |
|
||||
| 主动让出 | `runtime.Gosched()` |
|
||||
| 抢占 | sysmon 强制抢占(Go 1.14+) |
|
||||
|
||||
---
|
||||
|
||||
### Q5:M 寻找可运行 G 的过程? 🟡中等
|
||||
|
||||
## 参考答案
|
||||
|
||||
M 按以下优先级顺序寻找 G:
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
A[M 需要执行 G] --> B{本地队列 LRQ?}
|
||||
B -->|有| C[runqget 取出]
|
||||
B -->|无| D{全局队列 GRQ?}
|
||||
D -->|有| E[globrunqget 取一半]
|
||||
D -->|无| F{网络轮询 netpoll?}
|
||||
F -->|有| G[取出就绪的 G]
|
||||
F -->|无| H[从其他 P 偷 work-steal]
|
||||
H --> I[随机找一个 P 偷一半]
|
||||
|
||||
style C fill:#4CAF50,color:#fff
|
||||
style E fill:#2196F3,color:#fff
|
||||
style G fill:#9C27B0,color:#fff
|
||||
style I fill:#FF9800,color:#fff
|
||||
```
|
||||
|
||||
1. **本地队列**(LRQ):无锁 CAS 操作
|
||||
2. **全局队列**(GRQ):需要加锁
|
||||
3. **网络轮询器**(netpoll):IO 就绪的 G
|
||||
4. **Work-stealing**:随机找其他 P 偷一半
|
||||
|
||||
> [!tip] 💡 面试技巧
|
||||
> "Go 的调度器就像一个聪明的餐厅经理:先看自己桌有没有客人(本地队列),没有就去公共排队区(全局队列),再去门口看看有没有新到的人(netpoll),最后实在没人就从隔壁桌'借'几个客人来(steal)。"
|
||||
|
||||
---
|
||||
|
||||
### Q6:GMP 能不能去掉 P 层? 🟡中等
|
||||
|
||||
## 参考答案
|
||||
|
||||
**理论上可以,但会带来严重的性能问题。**
|
||||
|
||||
| 方案 | 问题 |
|
||||
|------|------|
|
||||
| 去掉 P(变成 GM) | 所有 M 争抢全局锁,高并发下锁竞争严重 |
|
||||
| 保留 P | 每个 P 独立本地队列,大部分情况无需全局锁 |
|
||||
|
||||
> [!note] 📝 核心考点
|
||||
> P 的价值在于实现了**无锁的本地调度**。M 绑定 P 后直接从本地队列取 G,只有队列空了才去偷取,大大减少了锁竞争。
|
||||
|
||||
---
|
||||
|
||||
### Q7:P 和 M 在什么时候被创建? 🟡中等
|
||||
|
||||
## 参考答案
|
||||
|
||||
| 组件 | 创建时机 | 说明 |
|
||||
|------|---------|------|
|
||||
| **P** | 初始化时一次性创建 | `schedinit()` → `procresize()`,数量为 GOMAXPROCS |
|
||||
| **M** | 按需创建 | 初始只有 m0,阻塞场景或 startm() 触发 |
|
||||
|
||||
**M 的创建条件:**
|
||||
- 所有现有 M 都在执行系统调用,但还有可运行的 G
|
||||
- 没有空闲 M 可以绑定 P
|
||||
|
||||
> [!info] 🔗 延伸阅读
|
||||
> - [[hzh/GolangStar/Go语言原理/gmp调度原理]] — schedinit / procresize 源码分析
|
||||
|
||||
---
|
||||
|
||||
### Q8:m0 是什么? 🟢简单
|
||||
|
||||
## 参考答案
|
||||
|
||||
m0 是 Go 启动时创建的**第一个 M**,对应主系统线程,贯穿整个生命周期。与其他动态创建的 M 不同,m0 是静态分配的。
|
||||
|
||||
**职责:**
|
||||
1. 执行 Go 程序的启动流程(调度器、内存管理器、GC 器初始化)
|
||||
2. 创建并运行第一个用户 goroutine(执行 main.main)
|
||||
3. 程序退出时负责清理工作
|
||||
|
||||
---
|
||||
|
||||
### Q9:g0 是什么? 🟡中等
|
||||
|
||||
## 参考答案
|
||||
|
||||
g0 是每个 M 拥有的**特殊调度协程**,不是普通用户协程:
|
||||
|
||||
| 特性 | g0 | 普通 goroutine |
|
||||
|------|----|--------------|
|
||||
| 栈空间 | 系统线程原始栈(8KB) | 可增长的分段栈(2KB 起) |
|
||||
| 用途 | 执行调度逻辑 | 执行业务代码 |
|
||||
| 创建方式 | 静态分配 | runtime.newg() |
|
||||
|
||||
**核心作用:** 专门负责执行调度器代码(goroutine 创建/销毁/调度决策)、垃圾回收、栈扫描、信号处理等运行时操作。
|
||||
|
||||
> [!warning] ⚠️ 高频陷阱
|
||||
> 为什么需要 g0?因为调度器不能在普通 goroutine 的栈上执行——会有栈空间冲突和递归调度的问题。g0 提供了独立的执行环境。
|
||||
|
||||
---
|
||||
|
||||
### Q10:g0 栈和用户栈如何切换? 🟡中等
|
||||
|
||||
## 参考答案
|
||||
|
||||
本质是 **SP 寄存器和栈指针的切换**:
|
||||
|
||||
1. **用户 -> g0**:通过 `mcall()` 保存当前 PC/SP 到 gobuf,将 SP 指向 g0 的栈
|
||||
2. **g0 -> 用户**:通过 `gogo()` 恢复用户 goroutine 保存的寄存器状态
|
||||
|
||||
这些切换在汇编文件中实现(`runtime·mcall` 和 `runtime·gogo`),直接操作 CPU 寄存器,确保原子性和高效性。
|
||||
|
||||
> [!info] 🔗 延伸阅读
|
||||
> - [[hzh/GolangStar/Go语言原理/gmp调度原理]] — 栈切换的汇编实现细节
|
||||
|
||||
## 关联笔记
|
||||
|
||||
- [[hzh/GolangStar/Go语言进阶/Goroutine]]
|
||||
- [[hzh/GolangStar/Go语言原理/gmp调度原理]]
|
||||
- [[hzh/GolangStar/Go面试题库/内存管理面试题]]
|
||||
|
||||
Reference in New Issue
Block a user