一句话结论
GMP 是 Go 运行时调度器的核心——G(Goroutine)是执行单元,M(OS 线程)是执行者,P(逻辑处理器)是执行所需的资源上下文。P 的数量由 GOMAXPROCS 决定,M 的数量由 Go Runtime 动态管理。
核心原理
G、M、P 分别是什么
为什么需要 P
如果没有 P:
G → G 在全局队列 → M 从全局队列取 G 执行
问题: 所有 M 从同一个全局队列取 G → 锁竞争严重
有了 P:
G → P 的本地队列(大部分)→ M 从绑定的 P 取 G
优势: 本地队列无锁,只在窃取时涉及锁
调度流程
1. 创建 Goroutine → 优先放入当前 P 的本地队列(runq)
2. 本地队列满 → 放入全局队列(runq,有锁)
3. M 从绑定的 P 的本地队列取 G 执行
4. 本地队列空 → 尝试 Work Stealing:
├── 从全局队列取
├── 从 Netpoller 取
└── 从其他 P 的本地队列偷一半
Work Stealing
P1: [G1][G2][G3][G4] ← M1 绑在 P1
P2: [](空了) ← M2 绑在 P2
M2 没有 G 可执行:
1. 尝试全局队列 → 空
2. 偷 P1 的后半部分: [G3][G4] 偷给 P2
3. M2 执行 G3
结果: P1: [G1][G2], P2: [G3][G4]
Hand Off
P1 绑定的 M1 执行 G 时发生系统调用(阻塞)
→ P1 与 M1 解绑
→ P1 寻找新的 M(或创建新 M)绑定
→ M1 阻塞等系统调用完成
→ 系统调用完后,G 尝试回到原来 P 的队列(如果 P 被其他 M 占用了,放入全局队列)
调度时机
项目中的应用
在 项目二-物联网AI-Agent中枢控制平台 中,设备接入层有上千个 Goroutine:
// 每个 MQTT 设备连接 → 一个 Goroutine
for _, device := range devices {
go handleDevice(device) // 上千个 Goroutine
}
// GOMAXPROCS 设置
// 默认值 = CPU 核数
// IoT 服务是 I/O 密集(大量 MQTT 连接、gRPC 调用)
// 可以适当增大 GOMAXPROCS(如设为核数的 2 倍)
特殊 Goroutine:g0 和 m0
m0
m0 是程序启动时的第一个 OS 线程(主线程),由 runtime 在进程启动时创建。
m0 的栈是操作系统分配的(主线程栈),不是 Go runtime 管理。
g0
每个 M 都有一个 g0——这是一个特殊的 Goroutine,用于执行调度逻辑。
g0 的栈由 Go runtime 分配(约 32KB),运行在操作系统线程栈上(不是普通 Goroutine 的栈上)。
g0 负责:
执行
schedule()函数(调度循环核心)Goroutine 的创建(
newproc1)GC 的协助标记
Goroutine 栈的扩容和收缩
普通 Goroutine (G)
→ 执行用户代码
→ 时间片用完 / 主动让出 / 系统调用
→ 切换到 g0(runtime 栈)
→ g0 调用 schedule() 选下一个 G
→ 切换到新的 G
→ 继续用户代码
为什么需要 g0? 调度代码需要一个稳定、固定的栈空间。如果直接用被抢占的 Goroutine 的栈来执行调度,栈可能不够用。g0 保证了调度逻辑始终有可靠的栈空间。
sysmon(系统监控线程)
sysmon 是一个特殊的 M(在 Go runtime 启动早期创建)
- 不需要 P 绑定
- 周期性运行(初始 20μs,逐步延长到 10ms)
- 职责:
① 检查网络事件(netpoller)→ 唤醒等待的 G
② 抢占执行时间过长的 G(> 10ms)
③ 回收空闲的 P
④ 处理 time.Timer 到期
⑤ GC 相关的触发
调度生命周期
G 的状态转换:
_Gidle → 刚创建
_Gdead → 已退出,可被重用
_Grunnable → 在运行队列中等待
_Grunning → 正在 M 上执行
_Gsyscall → 正在执行系统调用
_Gwaiting → 阻塞中(Channel/sleep/锁)
_Gpreempted → 被抢占
典型生命周期:
newproc1 创建 G (_Grunnable → 放入 P 队列)
→ schedule 选中 (_Grunning)
→ 用户代码执行
→ 1. 主动让出 (Gosched → _Grunnable)
→ 2. Channel 阻塞 (_Gwaiting → 被唤醒 → _Grunnable)
→ 3. 系统调用 blocking (_Gsyscall → handoff → _Grunnable)
→ 4. 被抢占 (_Gpreempted → _Grunnable)
→ 执行完成 → _Gdead → 放回 G 缓存池
调度时机细节
Go 1.14+ 的抢占式调度
Go 1.13 之前: 只在函数调用时检查抢占(morestack)→ 死循环无法被抢占
Go 1.14+: 基于信号的异步抢占
抢占过程:
1. sysmon 检测到 G 运行超过 10ms
2. 向 G 所在的 M 发送 SIGURG 信号
3. M 收到信号 → 进入信号处理函数 sighandler
4. sighandler 修改 G 的上下文(修改 PC/SP)
5. G 接下来会执行 runtime.asyncPreempt → 检查抢占标志
6. 如果确实需要抢占 → 调度让出
for { } // Go 1.14+ 即使死循环也能被抢占
局限: 即使在 Go 1.14+,某些 runtime 内部代码(如 GC 标记、锁持有临界区)仍不能被抢占。这是因为在这些区域被抢占会导致不一致状态。
协作式调度的检查点
即使在 Go 1.14+,以下点仍会主动检查是否应该让出:
函数调用序言(morestack 检查):编译器在栈帧检查时插入
runtime.Gosched()显式让出Channel 操作(chansend/chanrecv)中的阻塞前
runtime.GC()显式触发 GC锁操作(Mutex.Lock → semacquire)时的阻塞
P 的状态
Work Stealing 深入
偷取策略(findrunnable 的完整查找顺序)
M 绑定的 P 的本地队列空了 → 调用 findrunnable():
每 61 次检查一次全局队列(保证公平性)→
1. 从全局队列取 G(一次取多个,分摊锁开销)
2. 从 Netpoller 取就绪的 G
3. 从其他 P 偷 G(随机选一个 P,偷其本地队列后半部分)
4. 如果都没有 → 当前 M 进入自旋/休眠状态
为什么偷后半部分? 前半部分是最近放入的,可能正被缓存使用(CPU cache hot)。后半部分是较早的,对方更可能偷冷数据,减少缓存失效。
为什么每 61 次才查一次全局队列? 全局队列有锁。61 是素数——如果所有 P 都同时查全局队列,素数让它们错开节奏,减少撞锁概率。
自旋 M 的规则
M 空闲后不立即进入休眠:
1. 先进入"自旋"状态(大约尝试几次 findrunnable)
2. 如果在自旋期间有新 G 可达(被其他 P 放回、netpoller 唤醒),直接获取
3. 自旋次数用完 → 休眠(M 进入内核等待信号量)
自旋的必要条件:
- 自旋的 M 数量 < GOMAXPROCS / 2
- 自旋的 M 数量 ≤ 空闲的 P 数量
为什么要有自旋 M? 避免频繁的 M 休眠/唤醒(涉及内核态切换)。如果刚好有新 G 要来,自旋的 M 可以立即接手。
高频面试问题
Q: 能不能去掉 P 层?
30 秒回答: 技术上可以,但失去 P 后所有 M 从同一个全局队列取 G——锁竞争成为瓶颈。P 的本质是资源隔离+锁粒度降低:每个 P 有本地队列(无锁),100 个 P 就有 100 个本地队列并行调度。
深入回答: P 的设计借鉴了多核 CPU 的 per-CPU 缓存思想。如果把 P 去掉,所有 M 共享一个全局 G 队列——每次取 G 都要抢锁,在几十甚至上百个 M 的场景下,锁竞争远比调度本身更耗时。P 还承担了内存管理的角色(P 持有 mcache,分配小对象无需跨 P 同步)。
Q: GOMAXPROCS 设多少合适?
30 秒回答: 默认等于 CPU 核数即可。CPU 密集型不变。I/O 密集型(如 API 服务、Agent 推理等待)可适当增大(如核数 × 2),因为 Goroutine 大部分时间在等 I/O,P 处于空闲状态。
继续追问:
"设得过大有什么问题?" → 更多 P 意味着更多 mcache(每个 P 一个),内存开销增大。且 P 过多会增加 Work Stealing 的无效尝试次数。
"k8s 容器中怎么设?" → 不能用
runtime.NumCPU()(它看到的是宿主机核数),需要GOMAXPROCS环境变量或用automaxprocs包读取 cgroup 限制。
Q: Goroutine 栈多大?怎么增长的?
30 秒回答: 初始 2KB,最大可到 1GB(64 位)。栈增长是动态的——函数调用时检测栈是否够用,不够则分配新栈(copy 数据)或扩展现有栈。
深入回答: Go 1.3 之前用分段栈(stack segment,像链表),但存在"hot split"问题(在函数序言来回分配新段)。Go 1.4 开始改用连续栈(stack copying)——分配一个更大的连续栈,把旧栈数据 copy 过来,所有指针自动更新。栈扩容触发条件:SP < stackguard0(栈守卫值)。
Q: 系统调用阻塞和 Channel 阻塞,处理方式有什么不同?
30 秒回答: 系统调用阻塞时 M 和 P 解绑(Hand Off),P 找新的 M 继续执行其他 Goroutine。Channel 阻塞时 M 继续持有 P——当前 G 挂到 Channel 的等待队列,M 从 P 的本地队列取下一个 G 直接运行(同 M 不需要上下文切换)。
深入回答: 系统调用涉及内核态,M 真正被阻塞,Go runtime 无法在该 M 上切换 G——所以必须 Hand Off。Channel 阻塞完全在用户态——M 不阻塞,只是 G 被挂起。前者代价更大(可能涉及 M 的创建/唤醒)。这也是为什么 Go 强调"用 Channel 通信"——它的阻塞比系统调用阻塞代价小得多。
Q: G 创建后优先放哪个队列?
30 秒回答: 优先放当前 P 的本地队列。如果本地队列满了(容量 256),再把本地队列的前半部分 + 新 G 一起放到全局队列。
深入回答: 这个策略让新创建的 G 优先在同一个 P 上被执行(缓存亲和性)。但如果本地满了才推一部分到全局,保证其他 P 也有机会偷到 G——防止一个 P 独占太多 G。
常见错误
认为 Goroutine 数量不受限 → 每个 Goroutine 最少 2KB 栈 + 调度开销,百万级需几十 GB 内存
用
time.Sleep代替调度 → 应该用 Channel 或sync.WaitGroup混用 CPU 密集和 I/O Goroutine 不加控制 → CPU 密集会阻挡同 P 的其他 Goroutine
最小实验
// 观察 GOMAXPROCS 的影响
func main() {
runtime.GOMAXPROCS(1) // 单 P
// vs runtime.GOMAXPROCS(4) // 4 P
start := time.Now()
var wg sync.WaitGroup
for i := 0; i < 4; i++ {
wg.Add(1)
go func() {
defer wg.Done()
for j := 0; j < 1e8; j++ {} // CPU 密集
}()
}
wg.Wait()
fmt.Println(time.Since(start)) // 4 核时快 ~4 倍
}
速记
G=协程, M=线程, P=逻辑处理器(GOMAXPROCS)。P 本地队列(无锁)+全局队列。Work Stealing=偷其他 P 的 G。Hand Off=系统调用时 P 换 M。Go 1.14+ 信号抢占。栈 2KB 初始,动态增长。