essay
GMP中的用户态和内核态
#OS
为什么用户态 ↔ 内核态切换开销大?(在 GMP 调度模型)
一、先简单说:什么是用户态 / 内核态
- 用户态:应用代码跑的地方(你的 Go 程序、业务逻辑)
权限低,不能直接操作硬件、页表、线程。 - 内核态:操作系统内核跑的地方
管线程、内存、网络、磁盘,权限最高。
当你调用:
syscall(系统调用)read/write/sleep/mutex等
都会触发 用户态 → 内核态 → 用户态 的切换。
二、切换开销到底大在哪?
1. 上下文要完整保存与恢复
切换时 CPU 必须:
- 保存用户态寄存器、栈、指令指针
- 加载内核态栈、寄存器、页表、权限
- 执行完再切回去,又要恢复一遍
这不是简单跳转,是两套执行环境的完整交换。
- 页表切换 + TLB 刷新(最耗性能)
- 用户态、内核态用不同虚拟地址空间
- 切换会导致 TLB(页表缓存)失效
- 之后访问内存都要重新查表,速度暴跌
TLB 失效一次还好,高并发下频繁切换就是灾难。
- CPU 流水线被打断
现代 CPU 靠流水线、预测执行提速。
模式切换会:
- 清空流水线
- 废掉预取的指令
- 废掉分支预测
相当于 CPU 要“重新开始”,IPC(每周期指令数)大幅下降。
- 内核还要做安全性检查
内核不会无条件信任用户态:
- 校验指针合法性
- 检查权限
- 拷贝数据(用户栈 ↔ 内核栈)
这些都是纯额外开销。
三、放到 GMP 调度模型里:为什么 Go 要尽量避免系统调用?
这就是 GMP 最核心的设计动机之一。
- 传统线程(OS thread)的痛点
如果一个线程进入系统调用(比如 sleep、read):
- 用户态 → 内核态
- 线程阻塞在内核
- OS 把线程挂起 → 再次切换、再次调度
一次 IO = 至少两次模式切换 + 一次线程调度
高并发下成千上万次切换 → CPU 空转严重。
2. GMP 如何解决?
Go 做了一件关键事情:
把“阻塞”留在用户态,不轻易进内核态阻塞 M
- G(goroutine)阻塞时(channel、wait、sleep)
- 不执行系统调用、不切内核态
- 只在 Go 运行时(用户态)把 G 挂到等待队列
- P 直接抢下一个 G 继续跑
整个过程完全在用户态完成,0 内核切换开销。
- 只有万不得已才进内核
只有真正需要 OS 协助时才进入内核态:
- 真正的系统调用(syscall)
- 网络 IO、文件 IO
GMP 会做 hand off P: - M 进入内核阻塞
- P 去找新的 M 继续运行 G
避免整个 P 被卡住。
为什么用户态内核态切换开销大?
因为涉及上下文保存恢复、页表切换、TLB 刷新、CPU 流水线清空、安全性校验,是一套重量级的 CPU 环境切换,高并发下会产生巨大性能损耗。
放在 GMP 里的意义:
Go 的 GMP 调度器尽可能在用户态完成 G 的挂起、唤醒、切换,避免频繁进入内核态阻塞 OS 线程,从而消除大量模式切换开销,实现高并发、轻量调度。