标签:#Go #Golang #GMP #Assembly #Runtime #Concurrency
🚀 前言:GMP 的本质是“复用”
操作系统线程(OS Thread)太重了。创建一个线程需要 1-8MB 栈内存,切换一次需要进入内核态,耗时 1-2 微秒。
Go 的 GMP 模型本质上是一个二级调度系统:
- G (Goroutine): 仅仅是一个数据结构 (
struct g),包含自己的栈和指令指针 (PC),初始只占 2KB。 - M (Machine): 真正的 OS 线程。它不懂什么协程,它只知道执行代码。
- P (Processor): 逻辑处理器,维护了一个本地运行队列(Local Run Queue)。
核心秘密:M 并不直接执行 G 的代码,而是通过一个名为g0的特殊 Goroutine 来充当“调度中介”。
所有的切换,都不是 G 到 G 直连,而是 G1 -> g0 -> G2。
🧬 一、 切换的“黑盒”:gobuf
在 Go 的源码runtime/runtime2.go中,struct g里有一个至关重要的字段:sched。
它的类型是gobuf。这就是保存 Goroutine “灵魂”的地方。
typegobufstruct{spuintptr// Stack Pointer (栈顶指针)pcuintptr// Program Counter (指令指针/下一条要执行的指令)g guintptr// 属于哪个 Gctxt unsafe.Pointer retuintptrlruintptrbpuintptr// Base Pointer (栈底指针)}协程切换的本质,就是把 CPU 的寄存器(SP, PC, BP 等)保存到当前 G 的gobuf里,然后从目标 G 的gobuf里把寄存器恢复出来。
🎬 二、 切出 (Yield):mcall与g0
当一个 Goroutine 因为channel阻塞、系统调用或被抢占时,它会调用runtime.mcall。mcall的作用是:保留案发现场,切换到g0栈,开始调度。
让我们看runtime/asm_amd64.s中的汇编代码(Plan9 汇编):
// func mcall(fn func(*g)) TEXT runtime·mcall(SB), NOSPLIT, $0-8 // 1. 取出参数 fn (通常是 schedule 函数) MOVQ fn+0(FP), DI // 2. 获取当前运行的 g (我们称之为 g_cur) get_tls(CX) MOVQ g(CX), AX // AX = g_cur // 3. 保存现场!将寄存器值写入 g_cur.sched (gobuf) MOVQ 0(SP), BX // 保存调用者的 PC (返回地址) MOVQ BX, (g_sched+gobuf_pc)(AX) LEAQ 8(SP), BX // 保存调用者的 SP MOVQ BX, (g_sched+gobuf_sp)(AX) MOVQ BP, (g_sched+gobuf_bp)(AX) // 保存 BP // 4. 切换堆栈!从 g_cur 切换到 g0 MOVQ g_m(AX), BX // BX = g_cur.m (获取当前 M) MOVQ m_g0(BX), SI // SI = m.g0 (获取 g0) // 关键一跳:把 CPU 的 SP 寄存器修改为 g0 的栈顶 MOVQ (g_sched+gobuf_sp)(SI), SP // 5. 现在我们已经运行在 g0 的栈上了 // 设置当前 g 为 g0 MOVQ SI, g(CX) // 6. 执行调度函数 fn(g_cur) // 这里的 AX 还是旧的 g_cur,作为参数传给 schedule PUSHQ AX MOVQ DI, DX CALL DX人话翻译:
正在跑的 G 说:“我不行了,我要休息。”
于是它把自己的 SP、PC 记在小本本(gobuf)上,然后把 CPU 的 SP 指针瞬间指向了g0的栈。
瞬间,CPU 就以为自己一直是在g0上运行。接着,g0开始执行schedule()函数,去队列里找下一个幸运儿。
🚀 三、 切入 (Resume):gogo
schedule()找到下一个要运行的 G(我们叫它g_next)后,会调用runtime.execute,最终调用汇编实现的runtime.gogo。gogo的作用是:读取g_next的存档,通过修改寄存器,让 CPU “穿越”到g_next上次暂停的地方。
// func gogo(buf *gobuf) TEXT runtime·gogo(SB), NOSPLIT, $16-8 // 1. buf 是 g_next.sched MOVQ buf+0(FP), BX // BX = gobuf // 2. 从 gobuf 恢复寄存器 MOVQ gobuf_g(BX), DX MOVQ 0(DX), CX // 检查 g 是否为 nil get_tls(CX) MOVQ DX, g(CX) // 将 TLS (Thread Local Storage) 设置为 g_next MOVQ gobuf_sp(BX), SP // 🔥 恢复栈指针 SP!此刻切换完成 MOVQ gobuf_ret(BX), AX MOVQ gobuf_ctxt(BX), DX MOVQ gobuf_bp(BX), BP // 恢复 BP // 3. 准备起跳 // 清空 gobuf.sp,防止重复使用 MOVQ $0, gobuf_sp(BX) // 4. 获取下一条指令地址 PC MOVQ gobuf_pc(BX), BX // 5. 飞!跳转到 g_next 的代码位置 JMP BX人话翻译:g0说:“也就是你了,g_next。”
于是g0把g_next的小本本拿出来,把 CPU 的 SP、BP 全部改成g_next的值。
最后由JMP BX指令,直接跳转到g_next上次停下的代码行。
对 CPU 来说,仿佛什么都没发生过,只是继续执行下一行指令而已。
🔄 四、 宏观流程:G1 -> G2
将上述两个过程结合,就是一次完整的协程切换。
切换流程图 (Mermaid):
📊 五、 为什么 Go 切换这么快?
对比一下 Linux 线程切换和 Goroutine 切换:
| 维度 | Linux 线程切换 | Goroutine 切换 |
|---|---|---|
| 模式 | 用户态 -> 内核态 -> 用户态 | 纯用户态 |
| 内存 | 涉及页表切换、L1/L2 Cache 失效 | 仅涉及少量寄存器、L1 Cache 亲和性好 |
| 寄存器 | 保存所有通用寄存器 + AVX/FPU 状态 | 只保存 SP, PC, BP 等少数几个 |
| 耗时 | ~1-2 微秒 (us) | ~0.2 微秒 (us) |
结论:Go 通过在用户空间复写了一套微型操作系统(Runtime),避免了昂贵的系统调用(System Call)开销。
🎯 总结
- GMP 的 M是执行载体,G是数据存档。
- g0是连接 G1 和 G2 的桥梁,所有调度逻辑都在 g0 栈上跑。
- mcall负责“存档并切到 g0”。
- gogo负责“读档并切回用户 G”。
- Go 的汇编魔法
JMP和MOV SP实现了这一切。
Next Step:
既然看懂了切换,建议下载Delve调试器,在汇编层面单步调试一次 Goroutine 的切换过程。在断点处输入disass,亲眼看看MOVQ (g_sched+gobuf_sp)(AX), SP这行指令是如何改变世界线的。