重要
GMP 是 Go 的调度器模型:G(Goroutine)是执行体,M(Machine)是 OS 线程,P(Processor)是 M 的运行上下文。调度器通过工作窃取 + 无锁本地队列实现高效并发。
1. G、M、P 的角色
| 组件 | 全称 | 角色 |
|---|---|---|
| G | Goroutine | 执行体,每个 goroutine 对应一个 G |
| M | Machine | OS 线程,G 必须绑定到 M 才能执行 |
| P | Processor | M 的上下文,持有本地 G 队列(LRQ) |
默认 P 的数量 = GOMAXPROCS(通常等于 CPU 核心数)。每个 P 维护一个 FIFO 的本地 G 队列。
2. 工作窃取机制
当 M 上 P 的本地 G 队列空了,M 会按以下顺序寻找可执行的 G:
- 从全局 G 队列(GRQ)取;
- 随机选择一个其他 P,从其本地队列尾部窃取一半 G。
2.1 为什么是"随机窃取 + CAS"
窃取 = 受害者 LRQ 尾指针的修改——几次内存读取 + 整数计算 + 一次原子比较。
| 机制 | 成本 | 适用 |
|---|---|---|
| CAS | CPU 空转(低) | 操作极短时优于锁 |
| 互斥锁 | 线程挂起 + 内核态切换(高) | 长临界区 |
权衡公式: 操作成本 × 竞争概率 × 失败代价 越小,CAS 权重越高。
随机窃取避免了多个空闲 P 同时窃取同一个 P 的公平性问题。
3. Channel 操作中的调度协作
Channel 操作的阻塞与唤醒,展示了 GMP 三级协作模式:
| |
3.1 阻塞接管
当 G 在 Channel 上阻塞时,Channel 对象直接接管 G,将其记录到内部等待队列(sendq / recvq)。执行该 G 的 M 立即释放,转去执行 P 队列中的其他 G。
关键设计:解耦调度器与同步原语。G 不属于任何调度队列(LRQ/GRQ),而是由等待的目标 Channel 直接管理。
3.2 唤醒移交(direct handoff)
当配对的 G 操作 Channel 并唤醒等待队列中的 G 时:
- 被唤醒的 G 直接放入当前 M-P 的 LRQ 尾部
- 不经过全局队列
好处:
- 缓存亲和性:唤醒者刚操作完 Channel,相关数据极可能仍在当前 CPU 核心缓存中
- 无锁低延迟:P 的 LRQ 写入是无锁操作
3.3 为什么优先本地入队而非并行执行
被唤醒的 G 和唤醒者串行在同一 P 上执行,牺牲了瞬时并行度。但这是局部性能优化 vs 全局负载均衡的权衡:
- 优先保证:单次操作超低延迟 + 缓存效率
- 补偿机制:工作窃取。如果该 P 过载,空闲 P 会从 LRQ 尾部窃取 G(新入队的 G 恰好位于尾部),重新实现并行
3.4 理想 vs 现实
Channel 算法的假设与现实的出入:
| 假设 | 现实 |
|---|---|
| 缓存亲和性一定带来收益 | 若 LRQ 被窃取,虽然得到并发但丢失缓存;若 LRQ 中穿插无关 G,也会冲刷缓存 |
为什么仍保持这个设计 :调度器优化是概率性的——不追求每次完美,而是通过提高有利事件的统计概率来提升整体性能期望。
4. 总结
- GMP 是三级模型:G 执行体、M 线程、P 上下文;
- 工作窃取用 CAS 替代锁——用极低的 CPU 空转替代极高的线程挂起开销;
- Channel 阻塞与唤醒是阻塞托管 → 局部移交 → 全局窃取的三级协作;
- Go 调度器的设计哲学:先吃局部确定红利,再靠全局均衡消化副作用。