Go语言runtime全景图:从编译到GC,带你彻底吃透Go的底层血脉

runtime 就是 Go 语言运行时 ,是 Go 自带的一套 C+Go 写的底层库,不是操作系统,介于你的 Go 业务代码和操作系统内核之间。 我们写的 Go 代码,编译之后会把 runtime 打包进可执行文件,程序跑起来时 runtime 就在后台工作。

编译

  • 脚本语言:通过解释器来执行源码的语言(如PHP、Python),优点开发效率高,跨平台能力强,缺点执行效率低,对硬件的控制能力弱。
  • 编译语言:需要经过预先编译才能执行,优点执行效率高,对硬件资源的控制能力强,缺点调试效率较低,跨平台存在兼容问题。
参数 含义
-a 强制重新构建指定的包及其依赖包
-n 打印编译时会执行的命令,但不会执行
-x 打印并执行编译命令
-v 打印编译过程中处理的包名
-race 开启竞态条件检测,支持的平台有限
  1. -n:只预览编译要跑哪些命令,不真实编译,适合调试看流程。
  2. -x:真实执行编译,同时把每一步底层命令打印输出。
  3. -v:输出正在编译的包路径,方便看哪些包参与构建。
  4. -race:检测并发数据竞争,一般用于测试环境,会增大程序体积。

编译步骤

传统Gcc编译过程,如下图,GO语言的编译系统并没有直接使用传统的Gcc,Go编译器和链接器都是Go语言本身编写的,自己开发的一套效率更高的编译器。

bash 复制代码
词法分析 -> 语法分析 -> 类型检查 -> 中间代码生成 -> 代码优化 -> SSA生成 -> 机器码生成

1、词法分析 : 源码位置 $GOROOT/src/cmd/compile/internal/syntax/scanner.go

  • 由词法分析器(lexer)将源代码文件转换成Token序列
  • 每个Token表示一个词法单元
  • 特殊类型、基础类型、运算符和关键字

2、语法分析:语法分析器将语法分析器生成的token作为输入,解析token序列,然后将token序列转化成抽象语法树(AST),抽象语法树每个节点表示一个语法元素,比如常量、语句、函数等,每一个AST都对应一个Go语法文件,GO语言在解析过程中,发生的任何语法错误都会被语法解析器发现。 2.1 抽象语法树

  • 源代码语法的结构的一种抽象表示
  • 用树状的方式表示编程语言的语法结构
  • 每一个节点都表示源代码中的一个元素

为什么需要语法分析?

  • 语法分析的目的是将Token序列转换成抽象语法树
  • 抽象语法树可以用来辅助编译器进行语义分析
  • 用来检测比如类型是否匹配的问题

**3、中间代码生成 **

  • 将源码翻译成介于源代码和目标机器码中间的中间代码

为什么要先生成中间代码?便于对代码进行优化、解耦和复用。 编译过程代码优化做了哪些操作?死代码消除,函数内联,逃逸分析,闭包重写,循环不变量外提等

  • 死代码消除:去除一些无用代码以减少程序的体积大小,避免程序在执行过程中进行一些不必要的运算行为。
  • 函数内联:用函数体替换函数调用来减少因函数调用而造成的额外上下文切换开销。

4、SSA生成 (静态单赋值 Static Single Assignment 的缩写),IR将被转换为静态单赋值形式 5、机器码生成:针对具体目标架构,通过SSA进行多轮转换来执行代码优化,多轮转换后得到genssa再翻译成汇编代码,调用汇编器将它们转换为机器代码并生成最终的目标文件。

编译过程/启动过程

1、编译生成可执行文件:启动前必须先编程生成可执行文件,即时使用gorun执行也会生成。 2、加载程序镜像:操作系统加载程序镜像到内存中,为程序分配一块地址空间,检查程序的可执行文件头部信息,确定程序的入口点和其他必要信息。 3、初始化运行时环境:包括创建主goroutine,对调度器、内存分配器、垃圾回收器等运行时模块进行初始化。 4、调用main函数:创建新的goroutine启动runtime.main,先将这个goroutine放入m0绑定的P队列等待被调度。 5、执行main函数:启动m0获取可执行的goroutine,当执行到runtime.main时就会执行程序的main函数。 6、程序退出:main函数执行完毕或者程序遇到异常情况时,程序会退出。在退出前,运行时环境会清理资源,包括关闭所有goroutine、释放内存等。 ASM:Go的汇编语言Assembly简称ASM,Go源码经过编译器生成的机器指令,保存在.s文件中,基于Plan9风格的一种特形式的汇编语言。

深度理解编译过程

1、编译生成可执行文件:词法分析->语法分析-->类型检查-->中间代码生成->代码优化->SSA生成->机器码生成

2、操作系统加载可执行文件:Go程序启动时,操作系统会将程序镜像加载到内存中,并为程序分配一块地址空间。在这个过程中,操作系统会检查程序的可执行文件头部信息,确定程序的入口点和其他必要信息。

3、入口参数复制:操作系统通过入口参数的约定与应用程序进行沟通,为了支持从系统给运行时传递参数,Go程序在进行引导时将对这部分参数进行处理。程序刚刚启动时,栈指针SP的前两个值分别对应arsc和argv,分别存储参数的数量和具体的参数的值,在读取参数前会先进行赋值,即复制参数数量argc和参数值argv到栈上。

4、运行时检查 runtime.check():检查各类型的长度Sizeof,结构体字段的偏移量,CAS操作(比较与交换,Compare andswap,对一个内存地址上的值先比较再交换的无锁算法保证操作的原子性),atomic原子操作,指针操作,汇编指令,栈大小等

5、参数读取 runtime. args:对命令行中的参数进行处理,将参数数量赋值给argcint32,将参数值复制给argv**byte供相关函数使用

6、runtime.osinit:读取系统的CPU核心数。

7、runtime. schedinit0:进行各种运行时组件初始化,包括栈、内存分配器、调度器相关初始化、限制最大系统线程数量;创建P通过CPU 核心数和GOMAXPROCS环境变量确定 P的数量对GC垃圾收集器参数进行初始化(注意是仅设置参数,而垃圾回收器的启动是在runtime.main函数中进行的)

8、runtime. newproc :创建一个新的G,并将G放到runtime的队列中,这个G用于执行runtime.main函数(不是main.main)

9、runtime. mstart:开始启动调度器的调度,通过调度器的调度执行队列中入口方法是runtime.main的G。

10、runtime.main:开始调度,从队列里面取G进行执行,并执行runtime.main函数在runtime.main的执行中,会依次执行runtime中的init函数、启动GC收集器、执行用户包的init函数、执行用户的main函数

11、exit:当main函数执行完毕或者程序遇到异常情况时,程序会退出。在退出前,运行时环境会清理资源,包括关闭所有goroutine、释放内存等

扩展: M0是什么?有什么作用? m0是GoRuntime所创建的第一个系统线程,也叫主线程。声明方式跟其他m线程一样,m0会调度执行g0。

g0和普通的goroutine有啥区别? g0 是每个 M 内核线程自带的特殊 goroutine,拥有操作系统原生大栈,专门用来执行 runtime 调度、系统调用、栈切换逻辑;普通 goroutine 由 go 关键字创建,使用 Go 动态伸缩的用户栈执行业务代码,会被 P 调度队列管理。g0 不会进入 goroutine 运行队列。

GC

GC 本质:自动识别不再被程序引用的内存对象,回收其占用的堆内存,归还操作系统 / 内存池,避免内存泄漏,解放程序员手动内存释放工作。区分存活对象和死亡对象,回收死亡对象占用内存

  • 手动管理:C/C++,new/malloc 分配,free/delete 手动释放,忘记释放造成内存泄漏,重复释放造成程序崩溃。
  • 自动 GC:Java、Go、C#、Python 等语言,运行时做内存回收。

GC 的实现方式

1、追踪式 GC(可达性分析,主流:Java、Go、C#) 标记阶段实现 :找到全部 GC Roots(栈局部变量、静态变量、线程、JNI 句柄),从 Root 开始遍历对象引用图,把存活对象打上标记。 回收阶段的 3 种实现(对应三大基础算法) ① 标记‑清除 Mark‑Sweep

  1. 标记完存活对象
  2. 遍历整个堆,判断标记位,未标记对象调用内存释放,把内存归还空闲链表。
    • 空闲链表保存所有空闲内存块,分配内存时遍历链表找合适块。
    • 问题:产生大量内存碎片。

② 复制 Copying

  1. 内存划分两个空间 from /to
  2. 标记阶段找出存活对象
  3. 将存活对象全部复制拷贝到 to 空间,更新所有对象指针指向新地址
  4. from 空间整体清空,交换 from/to 角色。

③ 标记‑整理 Mark‑Compact

  1. 标记存活对象
  2. 将存活对象向堆的一端移动压缩,消除碎片
  3. 更新所有引用指针,指向对象新地址
  4. 末尾整块空间变为空闲内存。

2、引用计数实现(Reference Counting,主流:Python、Objective‑C、PHP) 给每个对象维护一个引用计数器,当对象被引用时计数器加一,当对象失去引用时计数器减一,当计数器变为零时立即回收该对象。 底层实现细节

  • 每次赋值、变量出作用域都要修改计数器,写操作开销大
  • 循环引用问题:A→B,B→A,外部无引用,ref_count 都不为 0,内存泄露
    • Python 解决方案:额外引入循环引用检测,定期扫描容器对象,标记循环垃圾回收。
  • 优点:回收即时;大部分场景不需要 STW。
  • 缺点:频繁读写计数器;无法天然处理循环引用。

Go语言 GC 的实现方式

Go 的 GC 属于追踪式 GC (Tracing GC)没有分代、没有新生代老年代 ;核心是 三色标记 + 混合写屏障 + 并发标记 + 并发清扫 ,整体是三色标记的并发 GC 实现。 Go语言不是用分代式GC的原因:

  • 逃逸分析的特性使得分代式GC在Go语言中没有优势
  • Go语言使用的内存分配算法无需分代式GC中的对象整理
  • Go语言的垃圾回收更注重垃圾回收与用户代码并发执行

Go语言GC流程

1、标记清除算法

  • 暂停程序业务逻辑,对可达和不可达的对象进行分类,做上标记。
  • 找出程序所有可达对象并做上标记
  • 解除暂停继续执行代码,重复上述过程直到程序生命周期结束

标记清除算法的问题:

  • STW需要暂停业务程序
  • 标记需要扫描整个heap,代价很大
  • 清除数据会产生堆内存碎片

2、三色标记法 三色标记法的具体过程: Go 1.5 之后正式使用三色标记 + 写屏障 实现并发 GC,GC 和用户 goroutine 可以同时运行,不需要全程 STW;三色标记是垃圾回收的标记阶段算法,核心思想:把对象分成黑、灰、白三种颜色,从根对象开始遍历可达对象。

颜色 含义
白色 初始状态;尚未扫描;GC 结束后白色对象就是垃圾,会被回收
灰色 对象本身已经被扫描(标记存活),但是它的子引用还没遍历完,放在灰色队列里等待处理
黑色 对象本身标记完成,所有子对象全部扫描完毕,确定存活,不会再被 GC 扫描
简单记住:
  • 白:没碰过,可能垃圾
  • 灰:自己标记了,孩子还没看
  • 黑:自己 + 全部孩子都标记完,存活

Go 的写屏障:混合写屏障(Hybrid Write Barrier,Go1.9 之后)Go1.5~1.8:Dijkstra 写屏障;Go1.9 之后统一使用混合写屏障,进一步降低 STW 混合写屏障的几个规则:

  • GC开始时优先扫描栈上的对象并将可达对象全部标记为黑色。
  • 当对象删除时触发删除写屏障,即将删除的对象标记为灰色。
  • 当对象新增时触发插入写屏障,将其标记为灰色。

触发GC

GC的触发时机和频率有关的因素: 1、当前程序的堆内存的增长率及占用率 :上个GC周期结束时,剩余的内存+目前为止分配到的内存超过了标记阶段标记的内存大小就会触发GC,此外程序需要分配内存的时候,如果没有足够的内存,或者是内存达到一定的比例,也会触发GC,回收那些不该使用的内存。 2、GC过程中CPU占用率 :GC本身会占用CPU,GC占用的数量和频率需要考虑当前的CPU占比,避免GC而拖慢了业务的执行速度。 3、垃圾回收器的定时调度触发 :GC会根据一定的时间间隔会进行触发,回收那些不在使用的内存,GC的触发除了强制执行周期时间(2分钟),还会根据GC触发比例,来动态执行GC的频率。 4、手动触发 :手动发 runtime.GCdebug.FreeOSMemory()

  • 自动调度的GC是异并发执行的,而手动调用则是同步串行的
  • debug.FreeOS MemoryO在GC执行之后还会清扫一次堆内存

内存泄漏

内存:可以把内存看成一个数组,内存地址可以看成数组的下标,CPU执行指令时,通过内存地址将物理内存上的数据载入到寄存器执行机器指令,对于频繁访问的指令还会缓存到CPU的缓存中。 寄存器:寄存器是计算机中用来存储数据的一种硬件设备,可以直接被中央处理器(CPU)访问,通常是在CPU内部或与CPU紧密相连的芯片上,速度极快。

虚拟内存: 为什么要使用虚拟内存?直接使用物理内存会导致内存利用率低,直接使用物理内存容易出现写冲突的问题,直接使用物理内存增加了开发人员的复杂度。

  • 用户程序只能通过虚拟地址来获取内存数据,操作系统会将虚拟地址映射到实际的物理地址。
  • 虚拟内存本质上是将磁盘当做最终的存储介质,物理内存则作为缓存。
  • 程序可以从虚拟内存中申请很大的内存空间。

**虚拟内存的实现:**虚拟内存一般是通过页表来实现的,默认大小4kb,磁盘和主内存之间的置换也是以页为单位来操作的,虚拟地址到物理地址的映射关系由页表(PageTable)记录。

Go语言内置运行时(runtime)实现了自己的管理方式,采用TCMalloc 思想 实现了一套自研的自动内存管理器,核心目标:降低系统调用开销、减少内存碎片、适配 goroutine 高并发、配合 GC 回收内存。 整体链路:

go 复制代码
new/make → runtime 分配器 (mcache → mcentral → mheap) → 向OS申请内存 → 使用 → GC标记清除 → 归还

对象分配完整流程

  1. 代码中 new(T) / make(),编译阶段转为调用 runtime.newobject
  2. 计算对象大小
    • 如果 >32KB:大对象,直接从 mheap 分配一块大 span。
    • 如果 ≤32KB:小对象,算出对应 size‑class。
  3. 当前 goroutine 绑定 P,从 P 的 mcache 取对应 size‑class 的 span
    • span 有空闲对象:直接返回对象地址,结束(无锁,最快)
    • span 用完:mcache 向 mcentral 申请一个空闲 span
  4. mcentral:
    • 有空闲 span:取出给 mcache。
    • 没有空闲 span:向 mheap 申请新 span。
  5. mheap:
    • 从 arena 找空闲页组装 span;不够就向 OS 申请内存(sysAlloc)。
  6. 返回对象地址给用户程序。
相关推荐
IT_陈寒1 小时前
Redis的DEL命令居然没删干净数据?这个坑我爬了半天
前端·人工智能·后端
罗超驿2 小时前
SpringMVC 请求参数接收详解|单个参数、多参数、对象参数、@RequestParam参数重命名
java·后端·postman·javaee
__zRainy__2 小时前
Node系列 · ORM:Sequelize 简介
数据库·后端·node.js
AskHarries3 小时前
OpenClaw 如何查看历史对话和响应时间?
后端
n8n3 小时前
Spring Boot 3.2+ 新特性、Spring AI 核心架构与 API Key 安全管理技术文档
后端
fireworks993 小时前
接口鉴权(401与403)
java·后端
n8n3 小时前
Java 21 虚拟线程(Virtual Threads)
后端
Csvn3 小时前
🐍 Day 5: Python 函数详解 — 参数、作用域与一等公民
人工智能·后端
wno7043 小时前
Spring Boot中使用Servlet
spring boot·后端·servlet