五家渠市网站建设_网站建设公司_定制开发_seo优化
2026/1/18 19:30:50 网站建设 项目流程

标签:#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):mcallg0

当一个 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。”
于是g0g_next的小本本拿出来,把 CPU 的 SP、BP 全部改成g_next的值。
最后由JMP BX指令,直接跳转到g_next上次停下的代码行。
对 CPU 来说,仿佛什么都没发生过,只是继续执行下一行指令而已。


🔄 四、 宏观流程:G1 -> G2

将上述两个过程结合,就是一次完整的协程切换。

切换流程图 (Mermaid):

G2 用户栈

g0 系统栈

G1 用户栈

JMP

G1 运行中

调用 mcall

mcall: 保存 G1 现场到 gobuf

mcall: 切换 SP 到 g0

执行 runtime.schedule

找到 G2

执行 runtime.execute

调用 gogo

gogo: 从 gobuf 恢复 G2 现场

G2 恢复运行


📊 五、 为什么 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 的汇编魔法JMPMOV SP实现了这一切。

Next Step:
既然看懂了切换,建议下载Delve调试器,在汇编层面单步调试一次 Goroutine 的切换过程。在断点处输入disass,亲眼看看MOVQ (g_sched+gobuf_sp)(AX), SP这行指令是如何改变世界线的。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询