3369 字
17 分钟
Go runtime
Go Runtime
一、编译与启动
1.1 Go编译过程
源代码 (.go) → 编译器 (go tool compile) → 中间表示 (SSA IR) → 生成汇编代码 → 汇编器 (go tool asm) → 可重定位目标文件 (.o) → 链接器 (go tool link) → 可执行程序
- 为什么要先生成中间代码
- 便于对代码优化、解耦和复用
1.2 Go程序启动过程
编译生成可执行文件 → 操作系统加载程序镜像到内存 → 执行入口函数(如_rt0_amd64_linux)→ 调用runtime·rt0_go → 初始化 Go runtime(TLS、CPU检测、调度器、内存分配器等)→ 创建main goroutine → 调用 runtime·main → 执行所有包的 init 函数(按导入依赖顺序)→ 调用 main.main 函数 → main.main 执行结束 → 程序退出
二、内存管理
2.1 虚拟内存基础
2.1.1 为什么需要虚拟内存
- 地址冲突:多个程序直接访问物理内存会冲突
- 安全问题:进程可以访问其他进程的内存
- 碎片问题:内存分配释放导致碎片
- 程序重定位:程序必须知道确切的物理地址
- 内存限制:物理内存有限,无法直接分配大块内存
2.1.2 虚拟内存优势
- 进程隔离:每个进程有独立的虚拟地址空间
- 内存保护:通过页表权限位(读/写/执行)实现硬件级访问控制
- 灵活映射:通过页表将离散的物理页映射为连续的虚拟空间
- 地址独立:程序无需关心实际物理地址,简化了开发
- 超额分配:可以分配超过物理内存的空间
2.1.3 CPU访问内存过程
CPU发出虚拟地址访问请求 ↓ MMU查询TLB ↓ TLB中有映射吗? / \ 是 否 ↓ ↓[快速路径] 查询内存中的页表获得物理地址 ↓ ↓ 页表项有效吗? ↓ / \ ↓ 是 否 ↓ ↓ ↓ ↓ [慢速路径] [最慢路径] ↓ 获得物理地址 触发缺页异常 ↓ ↓ ↓ ↓ 更新TLB 操作系统处理: ↓ ↓ 1. 判断访问合法性 ↓ ↓ 2. 分配物理页 ↓ ↓ 3. 内存不足时选择牺牲页 ↓ ↓ 4. 脏页写回磁盘 ↓ ↓ 5. 加载所需页 ↓ ↓ 6. 更新页表和TLB ↓ ↓ ↓ ↓ ↓ 重新执行指令 ↓ ↓ ↓ └────────┴────────────┘ ↓ 访问物理内存 ↓ 返回数据给CPU
2.2 Go内存管理
2.2.1 内存管理实现
- 基于TCMalloc设计的三级内存管理结构:
- mcache:P私有缓存,小对象(<=32KB)无锁分配
- mcentral:全局缓存,每个span class对应一个,需要加锁
- mheap:堆内存管理,大对象(>32KB)直接分配,管理所有内存
2.2.2 内存泄漏场景
- goroutine泄漏:channel阻塞或死循环导致goroutine无法退出
- slice切片:子切片引用导致底层数组无法释放
- map只增不删:全局map缓存持续增长
- time.Ticker:忘记调用Stop()
- CGO:未释放C分配的内存
- 闭包引用:闭包持有大对象引用
2.2.3 Go内存碎片控制
为什么Go内存基本不存在碎片?
Go内存管理的设计巧妙地避免了内存碎片问题,主要通过以下机制:
1. 分级管理架构
- Runtime向OS申请大块虚拟内存,然后自己管理
- 内存被划分成固定大小的页(8KB)
- 多个连续的页组成span(内存段)
- 页分配器维护空闲/占用的页段,按段长度分级管理,支持合并和拆分
2. Size Class机制
- Go预定义了67种对象大小规格(size class),如8字节、16字节、32字节等
- 分配时将请求尺寸映射到最接近的size class
- 相同大小的对象被分配在同一个span中(同类同居)
- 每个span按固定槽位切分,就像整理抽屉,同样大小的东西放一格
3. Span的生命周期管理
- span在mcentral ⇄ mcache之间流转(借/还机制)
- span内部维护空闲位图/空闲链表
- 分配时取下一个空槽,释放时把槽标记为空
- 当span用尽或全空时,会上沉/下沉到mheap
4. 智能的内存回收
- 整段回收:当一个span内的所有对象都被释放后,整个span可以被回收
- 页段合并:空闲的内存段可以和相邻的空闲段合并成更大的块
- 按需拆分:大的空闲块可以根据需要拆分成小块
5. 与Java的对比
- Go不像Java那样做对象移动压缩(不搬家)
- 但通过”同类同居 + 整段回收 + 页段合并”的组合策略
- 把不移动的坏处尽量弥补到了可接受的程度
总结:虽然Go不移动对象来压缩内存,但通过size class让相同大小对象聚集、整段式的内存回收、以及智能的页段合并机制,已经把内存碎片控制得很好。这就是为什么Go程序很少出现内存碎片问题的原因。
三、垃圾回收(GC)
3.1 GC基础概念
3.1.1 什么是GC
GC → 垃圾回收机制,自动化内存空间管理 → 找出死亡对象,然后作为垃圾回收
3.1.2 GC实现方式
- 追踪 → 从根对象(全局变量、执行栈、寄存器)出发,根据对象之间的引用关系一步步扫描整个堆 → java/go
- 标记清除 → 只执行标记和清除 → 会产生内存碎片
- 标记整理 → 标记后将存活的对象移到连续的内存区域,清除剩余空间 → 解决了内存碎片
- 复制 → 分为两个相等的区域,只使用一个区域,当一个快满了就将存活的移动到另一个区域,清除原来的区域 → 解决了内存碎片,但浪费内存空间
- 分代 → 根据对象不同的存活时间分为不同的代,针对不同代使用不同的算法
- 增量 → 为减少GC造成的停顿时间,将标记和清除分为多个小步骤,每个步骤之间可以让程序执行一段时间,让GC和程序交替执行 → 增加整体开销
- 并发 → 多个线程并发进行标记和清除 → 提高效率,需考虑并发问题
- 引用计数 → python
- 给每个对象维护一个引用计数器 → 当对象被引用时计数器加一,当对象失去引用时计数器减一 → 当计数器变为零时立即回收该对象 → 实现简单,回收及时/需要额外时间和空间,无法回收循环引用的对象
- 标记清除算法流程
- STW暂停程序
- 标记所有可达对象
- 清除未标记对象
- 恢复程序执行
3.1.3 GC触发时机
- 内存增长触发(主要机制)
- 由GOGC环境变量控制(默认100)
- 当前堆内存达到上次GC后堆内存的2倍时触发(GOGC=100时)
- 计算公式:trigger = heap_marked * (1 + GOGC/100)
- 定时触发(防止长时间不 GC)
- Go runtime 内部有一个最大 GC 间隔时间,默认2分钟
- 显式触发(手动)
- 调用 runtime.GC() 会立即触发一次完整 GC
3.2 Go的GC实现
3.2.1 Go的GC算法
- Go的GC算法历史
- 1.5 引入三色标记清除法 → 1.8 加入混合写屏障
- Go的GC特点
- 无分代(有逃逸分析的特性,所以有分代没有优势)、不整理(内存分配算法不需要对象整理)、并发的三色标记清除算法
3.2.2 三色标记法
-
标记过程
- 初始时,所有对象都是白色
- 将根对象(栈、全局变量)标记为灰色
- 从灰色集合取出一个对象
- 将该对象标记为黑色
- 遍历该对象的所有引用:
- 如果引用的是白色对象,标记为灰色
- 重复上述过程,直到灰色集合为空
- 结束时:黑色=存活,白色=垃圾
-
三色定义
- 白色:表示未被垃圾回收器访问到的对象
- 灰色:表示已被垃圾回收器访问到的对象,但其子对象还未被访问到
- 黑色:表示已被垃圾回收器访问到的对象,且其子对象也已被访问到
3.2.3 写屏障机制
-
为什么需要写屏障
- 标记过程需要STW → 防止对象被GC误杀
- 一个白色对象被黑色对象引用,同时灰色对象失去了这个白色对象的可达关系
-
屏障原理
- 程序执行过程中增加额外的判断机制,当满足一定条件就使用类似回调或者钩子
- 强三色不变式 → 不允许黑色对象直接引用白色对象的情况发生
- 弱三色不变式 → 允许黑色对象引用白色对象,但需要满足白色对象必须存在其他灰色对象对它的引用或白色对象的链路上游存在灰色对象
-
混合写屏障(Go 1.8+)
- 结合了Yuasa删除屏障和Dijkstra插入屏障的优点
- GC开始时无需STW扫描栈(栈上对象全部假定为黑色)
- GC期间:
- 栈上新创建的对象直接标记为黑色
- 堆上的指针写入触发屏障:
- 将被覆盖的对象标记为灰色(删除屏障)
- 将新写入的对象标记为灰色(插入屏障)
- 优势:整个GC期间无需重新扫描栈,减少STW时间
3.2.4 Go的GC流程
- STW(微秒级) → 开启写屏障,标记根对象
- 并发标记(与程序并发) → 三色标记,大部分工作在这里完成
- STW(微秒级) → 关闭写屏障,完成剩余标记
- 并发清除 → 回收垃圾对象
四、调度器(GMP)
4.1 GMP模型
4.1.1 为什么需要GMP
- 传统并发模型问题:
- 进程/线程创建销毁开销大
- 内存占用高(线程栈默认2MB,协程2KB)
- 内核态切换成本高
- 协程优势:用户态调度,轻量级,高并发
4.1.2 GMP组件
- G代表协程(goroutine),包含了协程的状态、栈、上下文等信息
- M代表machine,也就是工作线程,真正执行代码的OS线程
- m0:启动程序后的主线程,负责执行初始化操作和启动第一个G
- sysmon线程:系统监控线程,不需要绑定P即可运行,负责:
- 检测长时间运行的G并触发抢占
- 检测长时间阻塞的系统调用
- 触发强制GC(2分钟未GC时)
- 释放闲置超过5分钟的span物理内存
- P代表Processor,是工作线程m所需要的上下文环境,当p有任务就会创建或者唤醒m来执行
4.1.3 调度模型演进
- GM模型(早期) → GMP模型(Go 1.1+)
- 问题:全局队列锁竞争严重
- 解决:引入P本地队列,减少锁竞争
4.2 调度机制
4.2.1 调度流程
- 创建阶段
go func() 创建G ↓放入当前P的本地队列(容量256,实际可存储255个,因为有一个runnext槽位) ↓本地队列满?- 否 → 直接放入- 是 → 移动一半到全局队列后放入
- 调度阶段
M从P获取G执行(优先级):1. P.runnext(存储下一个要优先执行的G)2. P的本地队列(从队头获取)3. 全局队列(每执行61次调度tick会优先检查全局队列,防止全局队列饥饿)4. 其他P窃取(work stealing,窃取数量:(其他P队列长度+1)/2)5. 都没有 → M休眠等待唤醒
- 阻塞处理
G发生阻塞/系统调用 ↓M与P解绑(Handoff) ↓P绑定新M继续执行其他G ↓原M等待系统调用结束 ↓系统调用结束:- 获取空闲P → G继续执行- 无空闲P → G进全局队列,M进空闲池
4.2.2 调度时机
当前 G 停止执行,调度器选择另一个 G 来运行
- 主动调度:runtime.Gosched() → G进入全局队列
- 被动调度:channel阻塞、IO等待 → G等待队列
- 抢占调度:
- Go 1.14前:协作式(函数调用时检查)
- Go 1.14后:基于信号的抢占式调度(异步抢占)
- sysmon检测到G运行超过10ms会发送抢占信号
- 使用SIGURG信号实现真正的抢占
4.2.3 核心机制
- Work Stealing 工作窃取:
- 当P的本地队列为空时,从其他P窃取G
- 窃取数量:(其他P队列长度+1)/2
- 随机选择要窃取的P,避免竞争
- 保证负载均衡,提高CPU利用率
- Handoff 交接:M阻塞时,P转移给其他M
- Sysmon 系统监控:系统监控线程,触发抢占、GC等
五、并发与同步
5.1 内存同步访问的操作
- Channel 操作
- Mutex 和读写锁
- Select 语句
- 同步原语(如 sync.WaitGroup、sync.Cond 等)
5.2 time.Sleep与time.After对比
- 都可以等待一段时间,都让goroutine进入等待状态
- 返回不同
- time.Sleep():无返回值
- time.After():返回 <-chan Time
- 可中断性
- time.Sleep 不可中断
- time.After 可中断
- 实现方式
- time.Sleep:直接创建timer并等待
- time.After:创建timer + channel并返回channel
Go runtime
https://fuwari.vercel.app/posts/go_runtime/