深入理解 G-M-P 模型与用户态调度机制,探索 Go 如何在用户空间实现高性能并发
Go 语言的并发执行单元
操作系统线程的抽象
调度器的执行上下文
| 特性 | 传统内核线程 | Go 协程 (用户态) |
|---|---|---|
| 创建成本 | ~1MB 栈,需要系统调用 | ~2KB 栈,Go 运行时分配 |
| 切换成本 | 用户态→内核态→用户态 (~1-10μs) | 纯用户态切换 (~0.2-0.5μs) |
| 调度方式 | OS 内核抢占式调度 | Go 运行时协作式+抢占式 |
| 上下文保存 | 完整寄存器+栈+内核态 | 仅需保存寄存器+栈指针 |
| 数量限制 | 受限于系统线程数 (数千) | 可轻松创建数十万 |
| 调度延迟 | 取决于内核调度器 | 可控制在微秒级 |
使用 go func() 创建 G,加入 全局队列 或 本地队列。新协程初始栈仅 2KB,按需增长。
M (Machine/线程) 必须绑定一个 P 才能执行协程。P 持有运行队列和调度资源。GOMAXPROCS 决定 P 的数量。
优先从 P 的本地队列取 G执行,若为空则尝试从全局队列或偷取其他 P 的队列。
G 执行中可能:① 主动调用 runtime.Gosched() 让出;② 阻塞等待 (channel/锁);③ 被抢占式调度中断。
// 调度伪代码简化版
func schedule() {
var gp *g
// 1. 尝试从本地队列获取
if gp = getg().m.p.runq.pop(); gp != nil {
execute(gp)
return
}
// 2. 尝试从全局队列获取
if gp = globrunqget(); gp != nil {
execute(gp)
return
}
// 3. 工作窃取:从其他 P 偷一半
work stealing(getg().m.p)
}
🎯 P2 空闲时会随机选择其他 P,偷走其一半的协程(这里偷走 G3, G4)
本地队列为空 + 全局队列为空时触发
从目标 P 的本地队列尾部取走一半(约 50%)
随机选择其他 P,避免全局竞争
保证所有 CPU 核心充分利用,最小化空闲
普通调度:时间片到期后,M 从 P1 本地队列取下一个 G 执行
每个 P 有本地队列 + 全局队列 + 网络轮询器,形成高效的分布式调度
协程切换仅保存/恢复寄存器,无需进入内核,开销是内核线程的 1/100
空闲 P 主动从繁忙 P 偷任务,确保 CPU 100% 利用,无任务饥饿
M 个线程映射到 N 个协程,通过 P 解耦,实现高并发同时控制资源
核心洞察:
P 是调度资源的抽象(类似 CPU 核心),M 是执行载体,G 是任务单元。
通过 M:N 映射 + 工作窃取,Go 在用户态实现了高效、灵活的多核调度。