Go Runtime
Go Runtime
Go Runtime 主要由两大支柱构成:
- 调度器 (Scheduler): 负责管理和调度成千上万的 goroutine,让它们在少量的操作系统线程上高效运行。其核心就是 GMP 模型。
- 内存管理器 (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 调度过程的生命周期
下面列举一个完整的流程来理解它们是如何协同工作的:
- 程序启动:
- Go Runtime 初始化,创建一定数量的 M 和 P(数量由
GOMAXPROCS
决定)。 - 创建第一个 goroutine,用于执行 main 函数。
- Go Runtime 初始化,创建一定数量的 M 和 P(数量由
- 创建新的 Goroutine (以go myFunc()为例):
- 代码执行到 go myFunc()。
- Runtime 调用内部函数 newproc,在堆上创建一个新的 G 对象,并设置好它的指令指针指向 myFunc 的开头,以及初始化它的栈。
- 这个新的 G 被放入当前 M 所绑定的 P 的本地运行队列 (Local Run Queue, LRQ) 中。
- M 的执行循环:
- M 就像是是一个不知疲倦的工作者,它会不断地执行一个循环:
a. 从它绑定的 P 的 LRQ 中寻找一个可运行的 G。
b. 如果找到了,就设置好 CPU 的寄存器,然后执行这个 G 的代码。
c. 如果 G 执行完毕,M 就再次去 LRQ 中寻找下一个 G。
- M 就像是是一个不知疲倦的工作者,它会不断地执行一个循环:
- 发生阻塞 (关键调度点):
- 假设一个正在运行的 G (我们称之为 G1) 进行了一个阻塞的系统调用 (如文件读写、网络请求)。
- 这时,运行 G1 的 M (我们称之为 M1) 将会随着 G1 一起被操作系统阻塞。
- 调度器介入: M1 会与它绑定的 P (我们称之为 P1) 解绑。
- Runtime 会寻找一个空闲的 M (或者创建一个新的 M,称之为 M2),让它接管 P1。
- M2 开始执行 P1 本地队列中的其他 G。这样,仅仅一个 G 的阻塞,并不会阻塞整个程序的并行能力。
- 当 G1 的系统调用完成后,它会重新变为可运行状态,并被放回一个 P 的队列中,等待某个 M 来执行它。
- 工作窃取 (Work Stealing):
- 如果一个 M (比如 M1) 绑定的 P (比如 P1) 的本地队列已经空了,M1 不会闲着。
- 它会变成一个“小偷”,随机地去查看其他 P (比如 P2) 的本地队列。
- 如果发现 P2 的队列中有 G,M1 就会“窃取”一半的 G 到自己的 P1 队列中,然后开始执行。
- 这个机制实现了出色的负载均衡,确保了所有 CPU 核心都尽可能地保持忙碌。
内存管理器的详细过程
Go 的内存管理器主要负责两件事:为新对象分配内存,以及回收不再使用的对象(垃圾回收)。
内存分配
Go 的内存分配器非常精巧,它采用了分级分配的策略来减少锁竞争,提高效率。
- TCMalloc 思想: Go 将内存虚拟地址空间组织成一个金字塔结构,越往上,访问速度越快,但资源越少;越往下,资源越丰富,但访问成本越高。
- Go 的分配器借鉴了 Google 的 TCMalloc(
Thread-Caching Malloc
)。核心思想是为每个 P 维护一个本地的内存缓存 (mcache)。这是分配效率最高的层次。每个逻辑处理器 P (Processor) 都有一个自己的mcache
。mcache
是一个包含各种常用 Size Class 的mspan
列表的本地缓存。**(mspan
是 Go 内存管理的基本单位。它可以是 1 页 (8KB),也可以是多页。mspan 会被预先格式化,用于存储特定大小的对象,例如,一个 mspan 可能只用来存 16 字节的对象,另一个只用来存 32 字节的对象。这种按大小分类的方式称为 Size Class。)** - mcentral 负责管理特定 Size Class 的
mspan
。例如,有一个mcentral
专门管理所有用于 16 字节对象的 mspan。 - mheap 这是 Go 进程持有的整个虚拟内存空间,由 Go Runtime 统一管理。它将从操作系统申请到的大块内存(称为 Arenas)切分成许多**页 (Page)**,每页大小为 8KB。访问 mheap 需要加全局锁,成本最高。它不会直接把零散的页分配出去,而是将一组连续的页打包成一个
mspan
。
- Go 的分配器借鉴了 Google 的 TCMalloc(
- 分配流程:
- 当一个 goroutine 需要分配一小块内存时,它会直接从它所在的 P 的本地缓存
mcache
中获取,这个过程完全不需要加锁,速度极快。 - 如果
mcache
中没有足够大的内存块,它会向一个中心缓存 (mcentral
) 请求。这个过程需要加锁,但多个 P 共享一个mcentral
,竞争程度适中。 - 如果
mcentral
也没有,它会向页堆 (mheap
) 申请一块更大的内存,并将其切分。这个过程锁的粒度最大。 - 如果连
mheap
都没有足够的空闲页了,Go Runtime 就会通过系统调用向操作系统申请一块新的大内存(通常是几 MB)。
- 当一个 goroutine 需要分配一小块内存时,它会直接从它所在的 P 的本地缓存
这种设计使得绝大多数的小对象分配都能在无锁的情况下快速完成。
注意:对于超过 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)去操作流水线。如果一个工人在某条流水线上被卡住了(系统调用),主任会立刻派另一个工人去接管这条流水线,保证生产不中断。如果有的流水线任务堆积,而有的空闲,主任还会让工人去“匀一些”任务过来(工作窃取)。
- 内存管理器是工厂的后勤和保洁部门,它负责提供原材料(分配内存),并在任务完成后高效地清理废料(垃圾回收),保持整个工厂的整洁和高效运转。