一、核心概念(理论构建)
1. GMP 三大核心组件
G (Goroutine)
- 定义:代表一个 Goroutine,包含了函数指针、栈信息、状态等
- 重要状态 :
_Gidle: 刚刚分配,还未初始化_Grunnable: 在运行队列中,等待被执行_Grunning: 正在执行,已经和 M 绑定_Gsyscall: 正在执行系统调用_Gwaiting: 被阻塞(等待 channel、锁等)_Gdead: 刚刚退出或正在被初始化
M (Machine)
- 定义:操作系统线程的抽象,真正执行 Goroutine 的实体
- 特点 :
- M 必须持有 P 才能执行 G
- 数量可以动态增长(上限默认 10000)
- 存在特殊的 M0 和系统监控线程 sysmon
P (Processor)
- 定义:逻辑处理器,代表执行 Go 代码所需的资源
- 核心功能 :
- 维护本地可运行队列
runq(环形队列,最多 256 个 G) - 持有 mcache(用于内存分配)
- GOMAXPROCS 决定 P 的数量(默认等于 CPU 核心数)
- 维护本地可运行队列
2. GMP 模型的演进
less
旧模型(Go 1.0): G - M 直接绑定
问题:全局队列竞争激烈,锁开销大
新模型(Go 1.1+): G - P - M 三层结构
优势:
- P 的本地队列减少锁竞争
- Work Stealing 提高 CPU 利用率
- 系统调用时,M 和 P 解绑,P 可以继续被其他 M 使用
3. 调度器的关键机制
调度时机
- 主动调度 :Goroutine 调用
runtime.Gosched()主动让出 CPU - 被动调度:Channel 阻塞、锁等待、网络 IO 等
- 抢占式调度 :
- 协作式抢占(Go 1.13-):函数调用时检查抢占标记
- 基于信号的异步抢占(Go 1.14+):sysmon 通过信号强制抢占长时间运行的 G
Work Stealing(工作窃取)
- 当 P 的本地队列为空时,会尝试从以下位置窃取 G:
- 全局队列(需要加锁)
- 其他 P 的本地队列(窃取一半)
- netpoller(检查是否有就绪的网络 IO)
Hand Off(移交)
- 当 M 因系统调用阻塞时,会将 P 移交给其他空闲或新建的 M
- 保证 P 的数量始终不变,最大化 CPU 利用率
二、源码关键位置
核心文件
src/runtime/runtime2.go: G、M、P 结构体定义src/runtime/proc.go: 调度器核心逻辑(schedule、findrunnable、execute 等)src/runtime/asm_*.s: 汇编入口(不同架构)src/runtime/mgc.go: GC 相关
关键函数调用链
css
程序启动:
rt0_go (汇编)
-> schedinit() (初始化调度器)
-> newproc() (创建 main goroutine)
-> mstart() (启动 M)
-> schedule() (进入调度循环)
调度循环:
schedule()
-> findrunnable() (寻找可执行的 G)
-> execute() (执行 G)
-> goexit() (G 执行完毕)
-> schedule() (循环)
三、核心数据结构字段详解
g 结构体关键字段
go
type g struct {
stack stack // 栈内存范围 [stack.lo, stack.hi)
stackguard0 uintptr // 栈溢出检测
m *m // 当前绑定的 M
sched gobuf // 调度信息(PC、SP 等寄存器)
atomicstatus uint32 // 状态(原子操作)
goid int64 // goroutine ID
gopc uintptr // 创建此 goroutine 的 PC(用于栈追踪)
startpc uintptr // goroutine 函数的 PC
}
m 结构体关键字段
go
type m struct {
g0 *g // 用于执行调度代码的特殊 g(栈更大)
curg *g // 当前运行的 G
p puintptr // 当前绑定的 P
nextp puintptr // 唤醒 M 时,即将绑定的 P
spinning bool // 是否正在窃取 G
}
p 结构体关键字段
go
type p struct {
status uint32 // P 的状态
m muintptr // 绑定的 M
runqhead uint32 // 本地队列头
runqtail uint32 // 本地队列尾
runq [256]guintptr // 本地运行队列(循环队列)
runnext guintptr // 下一个要运行的 G(优先级最高)
gFree *g // 已终止的 G 的缓存列表
}
四、核心函数解析
1. schedule() - 调度循环入口
markdown
职责:找到下一个可运行的 G,并执行它
核心逻辑:
1. 每调度 61 次,从全局队列拿一个 G(防止全局队列饥饿)
2. 尝试从 P 的本地队列获取 G
3. 如果没有,调用 findrunnable() 进行全局搜索
4. 调用 execute() 执行 G
2. findrunnable() - 查找可运行的 G
markdown
查找顺序(Work Stealing):
1. 本地队列 (runq)
2. 全局队列 (globrunqget)
3. netpoller (网络轮询器)
4. 窃取其他 P 的队列 (stealWork -> runqsteal)
5. 再次检查全局队列和 netpoller
6. 如果还是没有,park M(休眠)
3. execute() - 执行 G
scss
职责:
1. 将 G 绑定到当前 M
2. 将 G 的状态改为 _Grunning
3. 调用 gogo() (汇编) 切换到 G 的栈并执行
4. sysmon() - 系统监控线程
markdown
职责:
1. 检查长时间运行的 G(>10ms),发送抢占信号
2. 回收长时间阻塞的 P
3. 触发强制 GC
4. 网络轮询器的超时检查
抢占机制(Go 1.14+):
- 向目标 M 发送 SIGURG 信号
- M 收到信号后,在信号处理函数中将当前 G 标记为可抢占
- 在下一次安全点检查抢占标记,切换到 g0 并调用 schedule()
五、实战要点
GODEBUG=schedtrace 输出解析
运行命令:
bash
GODEBUG=schedtrace=1000 go run main.go
输出示例:
ini
SCHED 1000ms: gomaxprocs=8 idleprocs=6 threads=12 spinningthreads=0 idlethreads=5 runqueue=0 [0 0 0 0 0 0 0 0]
字段含义:
1000ms: 程序运行时间gomaxprocs=8: P 的数量(GOMAXPROCS)idleprocs=6: 空闲的 P 数量threads=12: 当前 M 的总数spinningthreads=0: 正在窃取任务的 M 数量idlethreads=5: 空闲的 M 数量runqueue=0: 全局队列中的 G 数量[0 0 0 0 0 0 0 0]: 每个 P 的本地队列中的 G 数量
关键观察指标
- idleprocs 过高:说明 CPU 利用率不足,Goroutine 不够或有大量阻塞
- spinningthreads > 0:有 M 在不停窃取,说明存在负载不均衡
- runqueue 持续积累:全局队列有大量待处理的 G,可能有性能瓶颈
- 某些 P 的 runqueue 特别大:负载不均,可能某些 Goroutine 创建了过多子任务
六、常见面试题
Q1: 为什么需要 P?直接 G-M 不行吗?
A: P 的存在是为了减少锁竞争。每个 P 有独立的本地队列,避免了所有 M 竞争全局队列的问题。同时 P 可以在 M 阻塞时移交给其他 M,保证并行度。
Q2: Work Stealing 如何避免一直窃取不到任务导致的自旋?
A: findrunnable() 会有多轮尝试,如果多次尝试后仍找不到任务,M 会进入 park 状态(休眠),直到有新任务时被唤醒。
Q3: 什么情况下会创建新的 M?
A:
- 现有 M 都在执行且有空闲的 P
- M 因系统调用阻塞,需要 Hand Off P 时
- CGO 调用时(CGO 调用会阻塞 M)
Q4: Goroutine 的栈是如何增长的?
A: Go 使用分段栈(Segmented Stack,Go 1.2-)或连续栈(Contiguous Stack,Go 1.3+)。栈空间不足时会触发栈拷贝,将旧栈内容拷贝到新的更大的栈空间。