Go Runtime

Go Runtime

Go Runtime 主要由两大支柱构成:

  1. 调度器 (Scheduler): 负责管理和调度成千上万的 goroutine,让它们在少量的操作系统线程上高效运行。其核心就是 GMP 模型
  2. 内存管理器 (Memory Manager): 负责 Go 程序的内存分配和垃圾回收 (Garbage Collection, GC),确保内存使用的安全和高效。

调度器的详细过程(GMP模型)

  • G (Goroutine):
    • 本质: 一个执行单元,包含了它需要运行的函数指令、程序计数器 (PC)、栈指针 (SP) 和自己的栈。
    • 轻量级: 它的栈初始非常小 (约 2KB),可以根据需要动态增长和收缩。这就是为什么可以轻松创建成千上万个 goroutine,而操作系统线程的栈通常是固定的兆字节 (MB) 级别。
    • 状态: Goroutine 有多种状态,如 Grunnable (可运行)、Grunning (正在运行)、Gsyscall (等待系统调用)、Gwaiting (等待中,如 channel 或锁)。调度器根据这些状态来移动 G。
  • M (Machine):
    • 本质: 一个标准的操作系统线程 (OS Thread)。Go 代码的实际执行者。
    • 数量: Runtime 会根据需要创建和销毁 M,但有一个上限。它不是直接由 GOMAXPROCS 控制的。
  • P (Processor):
    • 本质: 一个逻辑处理器,是 G 和 M 之间的“调度上下文”或“执行许可证”。runtime.GOMAXPROCS(n) 设置的就是 P 的数量,默认等于 CPU 的核心数。这个值决定了你的程序能同时运行多少个 goroutine。
    • 关键作用: P 拥有一个**本地可运行 G 队列 (Local Run Queue, LRQ)**。这极大地减少了多个 M 争抢同一个全局队列的锁竞争,提高了调度效率。

GMP 调度过程的生命周期

下面列举一个完整的流程来理解它们是如何协同工作的:

  1. 程序启动:
    • Go Runtime 初始化,创建一定数量的 M 和 P(数量由 GOMAXPROCS 决定)。
    • 创建第一个 goroutine,用于执行 main 函数。
  2. 创建新的 Goroutine (以go myFunc()为例):
    • 代码执行到 go myFunc()。
    • Runtime 调用内部函数 newproc,在堆上创建一个新的 G 对象,并设置好它的指令指针指向 myFunc 的开头,以及初始化它的栈。
    • 这个新的 G 被放入当前 M 所绑定的 P 的本地运行队列 (Local Run Queue, LRQ) 中。
  3. M 的执行循环:
    • M 就像是是一个不知疲倦的工作者,它会不断地执行一个循环:
      a. 从它绑定的 P 的 LRQ 中寻找一个可运行的 G。
      b. 如果找到了,就设置好 CPU 的寄存器,然后执行这个 G 的代码。
      c. 如果 G 执行完毕,M 就再次去 LRQ 中寻找下一个 G。
  4. 发生阻塞 (关键调度点):
    • 假设一个正在运行的 G (我们称之为 G1) 进行了一个阻塞的系统调用 (如文件读写、网络请求)。
    • 这时,运行 G1 的 M (我们称之为 M1) 将会随着 G1 一起被操作系统阻塞。
    • 调度器介入: M1 会与它绑定的 P (我们称之为 P1) 解绑
    • Runtime 会寻找一个空闲的 M (或者创建一个新的 M,称之为 M2),让它接管 P1。
    • M2 开始执行 P1 本地队列中的其他 G。这样,仅仅一个 G 的阻塞,并不会阻塞整个程序的并行能力
    • 当 G1 的系统调用完成后,它会重新变为可运行状态,并被放回一个 P 的队列中,等待某个 M 来执行它。
  5. 工作窃取 (Work Stealing):
    • 如果一个 M (比如 M1) 绑定的 P (比如 P1) 的本地队列已经空了,M1 不会闲着。
    • 它会变成一个“小偷”,随机地去查看其他 P (比如 P2) 的本地队列。
    • 如果发现 P2 的队列中有 G,M1 就会“窃取”一半的 G 到自己的 P1 队列中,然后开始执行。
    • 这个机制实现了出色的负载均衡,确保了所有 CPU 核心都尽可能地保持忙碌。

内存管理器的详细过程

Go 的内存管理器主要负责两件事:为新对象分配内存,以及回收不再使用的对象(垃圾回收)。

内存分配

Go 的内存分配器非常精巧,它采用了分级分配的策略来减少锁竞争,提高效率。

  • TCMalloc 思想: Go 将内存虚拟地址空间组织成一个金字塔结构,越往上,访问速度越快,但资源越少;越往下,资源越丰富,但访问成本越高。
    1. Go 的分配器借鉴了 Google 的 TCMalloc(Thread-Caching Malloc)。核心思想是为每个 P 维护一个本地的内存缓存 (mcache)。这是分配效率最高的层次。每个逻辑处理器 P (Processor) 都有一个自己的 mcachemcache 是一个包含各种常用 Size Class 的 mspan 列表的本地缓存。**(mspan 是 Go 内存管理的基本单位。它可以是 1 页 (8KB),也可以是多页。mspan 会被预先格式化,用于存储特定大小的对象,例如,一个 mspan 可能只用来存 16 字节的对象,另一个只用来存 32 字节的对象。这种按大小分类的方式称为 Size Class。)**
    2. mcentral 负责管理特定 Size Classmspan。例如,有一个 mcentral 专门管理所有用于 16 字节对象的 mspan。
    3. mheap 这是 Go 进程持有的整个虚拟内存空间,由 Go Runtime 统一管理。它将从操作系统申请到的大块内存(称为 Arenas)切分成许多**页 (Page)**,每页大小为 8KB。访问 mheap 需要加全局锁,成本最高。它不会直接把零散的页分配出去,而是将一组连续的页打包成一个 mspan
  • 分配流程:
    1. 当一个 goroutine 需要分配一小块内存时,它会直接从它所在的 P 的本地缓存 mcache 中获取,这个过程完全不需要加锁,速度极快。
    2. 如果 mcache 中没有足够大的内存块,它会向一个中心缓存 (mcentral) 请求。这个过程需要加锁,但多个 P 共享一个 mcentral,竞争程度适中。
    3. 如果 mcentral 也没有,它会向页堆 (mheap) 申请一块更大的内存,并将其切分。这个过程锁的粒度最大。
    4. 如果连 mheap 都没有足够的空闲页了,Go Runtime 就会通过系统调用向操作系统申请一块新的大内存(通常是几 MB)。

这种设计使得绝大多数的小对象分配都能在无锁的情况下快速完成。

注意:对于超过 32KB 的大对象,分配过程会绕过 mcache 和 mcentral,直接由 mheap 进行分配,因为对大对象的缓存意义不大。

垃圾回收 (GC)

Go 的 GC 旨在最大程度上减少应用程序的暂停时间(Stop-The-World, STW)。它采用的是一个并发的三色标记-清除 (Tri-color Mark-and-Sweep) 算法。

三色抽象:

  • 白色: 初始状态,代表潜在的垃圾。
  • 灰色: 已被发现,但其引用的对象还没被扫描。
  • 黑色: 已被发现,且其引用的所有对象都已被扫描。GC 的目标就是把所有活动对象都变成黑色。

GC 过程:

准备阶段 (STW): 一个非常短暂的暂停。开启**写屏障 (Write Barrier)**。写屏障是一段由编译器插入的代码,用于记录在 GC 并发执行期间,程序对内存指针的修改。这是保证并发 GC 正确性的关键。

标记阶段 (并发):

  • 此阶段与应用程序并发执行。

  • 从根对象(全局变量、每个 goroutine 栈上的变量等)开始,将它们标记为灰色,并放入一个队列。

  • GC 的后台工作 goroutine 会不断从队列中取出灰色对象,将其引用的所有白色对象也标记为灰色并放入队列,然后将自身标记为黑色。

  • 这个过程是与你的应用程序代码并发执行的。你的 goroutine 仍然在运行和修改对象。写屏障会确保即使在标记过程中指针关系发生了变化,GC 也能正确地追踪到所有活动对象。

标记终止 (stop the world, STW): 另一个短暂的暂停(通常只有微秒级别的暂停)。完成标记工作,关闭写屏障。

清除阶段 (并发):

  • 遍历堆内存,将所有仍然是白色的对象回收,它们的内存可以被重新分配。这个过程也是并发的。

通过这种方式,Go 的 GC 将 STW 时间控制在亚毫秒级别,对应用程序的性能影响极小。

并发 GC 的挑战与解决方案:写屏障 (Write Barrier)

如果 GC 在标记时,应用程序也在同时运行,就会出现一个严重问题:

场景:对象 A (黑色) 已经扫描完毕。此时,应用程序执行 A.ptr = B,让 A 引用了一个对象 B (白色)。然后,应用程序删除了其他所有对 B 的引用。

如果没有特殊机制,GC 不会再回头扫描黑色的 A,因此 B 将永远不会被发现,最终被错误地当作垃圾回收掉。这就是对象丢失问题。

为了解决这个问题,Go 引入了写屏障 (Write Barrier) 技术。写屏障是编译器插入的一段代码,它“监视”所有在堆上的指针修改操作。

混合写屏障 (Hybrid Write Barrier) 的核心思想

当一个指针被修改时,如果被指向的对象是白色的,写屏障会立即将其标记为灰色。

这样就保证了,一个黑色的对象永远无法直接指向一个白色的对象。这个简单的规则,确保了在并发标记过程中,所有活动对象最终都能被正确地标记为黑色。

总结:Go Runtime 的协同工作

Go Runtime 就像一个高效的现代化工厂:

  • Goroutines (G) 是成千上万个等待处理的任务
  • Processors (P) 是固定数量的流水线 (GOMAXPROCS 条)。
  • OS Threads (M)工人
  • 调度器车间主任,它不断地将任务(G)分配到流水线(P)上,并指挥工人(M)去操作流水线。如果一个工人在某条流水线上被卡住了(系统调用),主任会立刻派另一个工人去接管这条流水线,保证生产不中断。如果有的流水线任务堆积,而有的空闲,主任还会让工人去“匀一些”任务过来(工作窃取)。
  • 内存管理器是工厂的后勤和保洁部门,它负责提供原材料(分配内存),并在任务完成后高效地清理废料(垃圾回收),保持整个工厂的整洁和高效运转。