网易首页 > 网易号 > 正文 申请入驻

字节开源 Go 内存引用分析工具,内存泄露一目了然!

0
分享至

作者 | 谢正尧

在 Go 语言开发中,内存泄漏问题往往难以定位,传统的 Pprof 工具虽然能提供一定帮助,但在复杂场景下其能力有限。为了更高效地分析和解决这些问题,CloudWeGo 团队开发了一款新的工具——Goref。Goref 基于 Delve,能够深入分析 Go 程序的堆对象引用,显示内存引用的分布,帮助开发者快速定位内存泄漏或优化 GC 开销。该工具不仅支持运行时进程的分析,还能分析核心转储文件,为 Go 开发者提供了一个强大的内存分析工具。项目已开源在 GitHub 上,欢迎社区贡献和使用。

Pprof 的局限性

作为 Go 研发有时会遇到内存泄露的情况,大部分人第一时间会尝试打一个 heap profile 看问题原因。但很多时候,heap profile 火焰图对问题排查起不到什么帮助,因为它只记录了对象是在哪创建的。在一些复杂业务场景下,对象经过多层依赖传递或者内存池复用,几乎已经无法根据创建的堆栈信息定位根因。

以如下 heap profile 为例,FastRead 函数栈是 Kitex 框架反序列化函数,如果业务协程泄露了请求对象,实际上并不能反映到对应泄露的代码位置,而只能体现在 FastRead 函数栈占据了内存。

众所周知, Go 是带 GC 的语言,一个对象无法释放,几乎 100% 是由于 GC 通过引用分析将其标记为存活。而同样作为 GC 语言,Java 的分析工具就更加完善了,比如 JProfiler 可以有效地展示对象引用关系。因此,我们也想在 Go 上实现一个高效的引用分析工具,能够准确直接地告诉我们内存引用分布和引用关系,帮我们从艰难的静态分析中解放出来。好消息是,我们已基本完成了这个工具的开发工作,已开源在 https://github.com/cloudwego/goref 仓库下,使用方式见 README 文档。

以下将分享这个工具的设计思路和详细实现。

思 路

GC 标记过程

在讲具体实现之前,我们先回顾一下 GC 是怎么标记对象的存活的。

Go 采用类似于 tcmalloc 的分级分配方案,每个堆对象在分配时会指定到一个 mspan 上,它的 size 是固定的。在 GC 时,一个堆地址会调用 runtime.spanOf 从多级索引中查找到这个 mspan,从而得到原始对象的 base address 和 size。

// simplified code
func spanOf(p uintptr) *mspan {
ri := arenaIndex(p)
ha := mheap_.arenas[ri.l1()][ri.l2()]
return ha.spans[(p/pageSize)%pagesPerArena]
}

通过 runtime.heapBitsForAddr 函数可以获得一个对象地址范围内的 GC bitmap。而 GC bitmap 中标记了一个对象所在内存的每 8 字节对齐的地址是否是一个指针类型,从而判断是否进一步标记下游对象。

例如以下 Go 代码片段:

type Object struct {
A string
B int64
C *[]byte
}
// global variables
var a = echo()
var b *int64 = &echo().B
func echo() *Object {
bytes := make([]byte, 1024)
return &Object{A: string(bytes), C: &bytes}
}

GC 在扫描变量 b 时,不是只简单地扫描 B int64 这个字段的内存,而是通过 mspan 索引查找出 base 和 elem size 后再进行扫描,因此,字段 A 和 C 以及它们的下游对象的内存都会被标记为存活。

GC 扫描变量 a 变量时,发现对应的 GC bit 是 1001,怎么理解呢?可以认为是 base+0 和 base+24 的地址是指针,要继续扫描下游对象,这里 A string 和 C *[]byte 都包含了一个指向下游对象的指针。

基于以上的简要分析,我们可以发现,要找到所有存活的对象,简单的原理就是从 GC Root 出发,挨个扫描对象的 GC bit,如果某个地址被标记为 1,就继续向下扫描,每个下游地址都要确定它的 mspan,从而获取完整的对象基地址、大小和 GC bit。

DWARF 类型信息

然而,光知道对象的引用关系对于问题排查几乎没有任何帮助。因为它不能输出任何有效的可供研发定位问题的变量名称。所以,还有一个很关键的步骤是,获取到这些对象的变量名和类型信息。

Go 本身是静态语言,对象一般不直接包含其类型信息,比如我们通过 obj=new(Object) 调用创建一个对象,实际内存只存储了 A/B/C 三个字段的值,在内存中只有 32 字节大小。既然如此,有什么办法能拿到类型信息呢?

Goref 的实现

Delve 工具介绍

有过 Go 开发经历的同学应该都用过 Delve,如果你觉得自己没用过,不要怀疑,你在 Goland IDE 上玩的代码调试功能,底层就是基于 Delve 的。说到这里,相信大家已经回忆起 Debug 时调试窗口的画面了,没错,调试窗口所展示的变量名,变量值,变量类型这些信息,不正是我们需要的类型信息吗!

$ ./dlv attach 270
(dlv) ...
(dlv) locals
tccCli = ("*code.byted.org/gopkg/tccclient.ClientV2")(0xc000782240)
ticker = (*time.Ticker)(0xc001086be0)

那么,Delve 是怎么获取这些变量信息的呢?在我们 attach 进程时,Delve 从 /proc/ /exe 读取软链接到实际 elf 文件路径的可执行文件。Go 编译时会生成一些调试信息,以 DWARF 标准格式存储在可执行文件的 .debug_* 前缀的 section 节里。引用分析所需要的全局变量和局部变量的类型信息就可以通过这些 DWARF 信息解析得到。

对于全局变量:Delve 迭代读取所有 DWARF Entry ,解析出带 Variable 标签的全局变量的 DWARF Entry。这些 Entry 包含了 Location、Type、Name 等属性。

其中,Type 属性记录了它的类型信息,按 DWARF 格式递归遍历,可以进一步确定变量的每一个子对象类型;

Location 则是一个相对复杂的属性,它记录了一个可执行的表达式或者一个简单的变量地址,作用是确定一个变量的内存地址,或者返回寄存器的值。在全局变量解析时,Delve 通过它获得了变量的内存地址。

Goroutine 中的局部变量解析的原理与全局变量大同小异,不过还是要更复杂一些。比如需要根据 PC 确定 DWARF offset,同时 location 表达式也会更复杂,还涉及到寄存器访问。这里不再展开。

GC 分析的元信息构建

通过 Delve 提供的进程 attach 和 core 文件分析功能,我们还可以获取到内存访问权限。我们仿照 GC 标记对象的做法,在工具的运行时内存中构建待分析进程的必要元信息。这包括:

  1. 待分析进程的各个 Goroutine stack 的地址空间范围,并包括每个 Goroutine stack 存储 gcmask 的 stackmap,用来标记是否可能指向一个存活的堆对象;

  2. 待分析进程的各个 data/bss segment 的地址空间范围,包括每个 segment 的 gcmask,也是用来标记是否可能指向一个存活的堆对象;

  3. 以上两步都是获取 GC Roots 的必要信息;

  4. 最后一步是读取待分析进程的 mspan 索引,以及每个 mspan 的 base、elem size、gcmask 等信息,在工具的内存中复原这个索引;

以上步骤是大概的流程,其中还有一些细节问题的处理,例如对 GC finalizer 对象的处理,以及对 Go 1.22 版本 allocation header 特性的特殊处理,这里不再展开。

DWARF 类型扫描

万事俱备,只欠东风。不管是堆扫描的 GC 元信息,还是 GC Root 变量的类型信息都已经完成解析。那么所谓的“东风”就是最关键的对象引用关系分析环节了。

对于每个 GC Root 变量,我们调用 findRef 函数,按不同的 DWARF 类型访问对象的内存,假设是一个可能指向下游对象的指针,则读取指针的值,在 GC 元信息里找到这个下游对象。这时,按前所述,我们得到了对象的 base address、elem size、gcmask 等信息。

如果对象被访问到,记录一个 mark bit 位,以避免对象被重复访问。通过 DWARF 子对象类型构造一个新的变量,再次递归调用 findRef 直至所有已知类型的对象被全部确认。

然而,这种引用扫描方式和 GC 的做法是完全相悖的。主要原因在于,Go 里面有大量不安全的类型转换,可能某个对象在创建后是带了指针字段的对象,比如:

func echo() *byte {
bytes := make([]byte, 1024)
obj := &Object{A: string(bytes), C: &bytes}
return (*byte)(unsafe.Pointer(obj))
}

从 GC 的角度出发,虽然 unsafe 转换了类型为 *byte,但并没有影响其 gcmask 的标记,所以在扫描下游对象时,仍然能扫描到完整的 Object 对象,识别到 bytes 这个下游对象,从而将其标记为存活。

但 DWARF 类型扫描可做不到,在扫描到 byte 类型时,会被认为是无指针的对象,直接跳过进一步的扫描了。所以,唯一的办法是,优先以 DWARF 类型扫描,对于无法扫到的对象,再用 GC 的方式来标记。

要实现这一点,做法是每当我们用 DWARF 类型访问一个对象的指针时,都将其对应的 gcmask 从 1 标记为 0,这样在扫描完一个对象后,如果对象的地址空间范围内仍然有非 0 标记的指针,就把它记录到最终标记的任务里。等到所有对象通过 DWARF 类型扫描完成后,再把这些最终标记任务取出来,以 GC 的做法二次扫描。

例如,上述 Object 对象访问时,其 gcmask 是 1010,读取字段 A 后,gcmask 变成 1000,如果字段 C 因为类型强转没有访问到,则在最终扫描的 GC 标记时就会被统计到。

除了类型强转外,引用内存越界问题也很常见,如上文示例代码 var b *int64 = &echo().B 所示,字段 A 和 C 都属于无法被 DWARF 类型扫描的内存,也会在最终扫描时被统计。

最终扫描

上述的被类型强转的字段,或者因为超过了 DWARF 定义的地址范围而无法访问到的字段,又或者像 unsafe.Pointer 这种无法确定类型的变量,都会在最终扫描时被标记。因为这些对象没法确定具体的类型,所以不需要专门输出,只需要把 size 和 count 记录到已知的引用链路中即可。

在 Go 原生实现中,有不少常用库都采用了 unsafe.Pointer,导致子对象识别出现问题,这类类型要做特殊处理。

输出文件格式

所有对象扫描完毕后,将引用链路及其对象数、对象内存空间输出到文件,文件对齐 pprof 二进制文件格式,采用 protobuf 编码。

  1. 输出的根对象格式:

  • 栈变量格式:包名 + 函数名 + 栈变量名github.com/cloudwego/kitex/client.invokeHandleEndpoint.func1.sendMsg

  • 全局变量格式:包名 + 全局变量名github.com/cloudwego/kitex/pkg/loadbalance/lbcache.balancerFactories

输出的子对象格式:

  • 输出子对象的类型名,形如:net.Conn;

  • 如果是 map key 或 value 字段,则以 $mapkey. (type_name) 或 $mapval. (type_name) 的形式输出;

  • 如果是数组的元素,以 [0]. (type_name) 格式输出,大于等于 10 的以 [10+]. (type_name) 格式输出;

效果展示

以下是一个真实业务用工具采样后的对象引用火焰图:

图中展示了每个 root 变量的名称,以及其引用的字段名和类型名。注:由于 Go1.23 之前 DWARF Info 没有支持闭包类型的字段 offset,所以闭包变量 wpool.(*Pool).GoCtx.func1.task 暂时无法展示下游对象。选择 inuse_objects 标签,还可以查看对象数分布火焰图:

项目地址:
https://github.com/cloudwego/goref,点击“阅读原文”即可直达。

特别声明:以上内容(如有图片或视频亦包括在内)为自媒体平台“网易号”用户上传并发布,本平台仅提供信息存储服务。

Notice: The content above (including the pictures and videos if any) is uploaded and posted by a user of NetEase Hao, which is a social media platform and only provides information storage services.

相关推荐
热点推荐
你有知道哪些炸裂的秘密?网友:我有个秘密说出来肯定大家要笑死

你有知道哪些炸裂的秘密?网友:我有个秘密说出来肯定大家要笑死

带你感受人间冷暖
2026-01-29 00:10:05
霍尔木兹海峡开了一条“缝”,1.4亿桶在途油在路上,下周一开盘,多头还扛得住吗?

霍尔木兹海峡开了一条“缝”,1.4亿桶在途油在路上,下周一开盘,多头还扛得住吗?

汇通网
2026-03-21 14:54:04
1-0:东北大帅3轮首胜,郑智谢天谢地谢人!

1-0:东北大帅3轮首胜,郑智谢天谢地谢人!

工从昊懂球阿靖
2026-03-21 23:20:37
东莞一鞋材厂起火,大火吞没厂房燃起冲天黑烟,当地应急:已经扑灭,未造成人员伤亡

东莞一鞋材厂起火,大火吞没厂房燃起冲天黑烟,当地应急:已经扑灭,未造成人员伤亡

潇湘晨报
2026-03-21 17:49:38
美国签证政策大收紧!1.5万美元才能入境?今天新增12国

美国签证政策大收紧!1.5万美元才能入境?今天新增12国

新浪财经
2026-03-19 11:46:25
冯东生:天津市原顾问委员会常委、市委组织部原副部长

冯东生:天津市原顾问委员会常委、市委组织部原副部长

坠入二次元的海洋
2026-03-21 19:35:38
A股:刚刚五部门发声,金融法案征求意见,下周一散户走还是留?

A股:刚刚五部门发声,金融法案征求意见,下周一散户走还是留?

夜深爱杂谈
2026-03-21 18:44:14
欠钱不还还删好友?傅盛深夜炮轰周鸿祎,互联网师徒恩怨再度上演

欠钱不还还删好友?傅盛深夜炮轰周鸿祎,互联网师徒恩怨再度上演

一窥究竟
2026-03-21 21:11:23
全世界都被特朗普耍了?打击伊朗只是幌子,真实目的终于浮出水面

全世界都被特朗普耍了?打击伊朗只是幌子,真实目的终于浮出水面

夕阳渡史人
2026-01-30 09:47:08
明晚开播!CCTV8黄金档又一部大制作剧来袭!阵容好强大

明晚开播!CCTV8黄金档又一部大制作剧来袭!阵容好强大

动物奇奇怪怪
2026-03-21 19:59:17
霍尔木兹海峡传重大利好,国内或将迎来历史性涨幅,抓紧入场!

霍尔木兹海峡传重大利好,国内或将迎来历史性涨幅,抓紧入场!

次元君情感
2026-03-21 11:32:32
汪小菲明确表示不会在台北买房,马筱梅通过汪宝儿示好张兰引热议

汪小菲明确表示不会在台北买房,马筱梅通过汪宝儿示好张兰引热议

草莓信箱
2026-03-21 20:45:36
20万彩礼娶回个“祖宗”!班不上、活不干,一网友哭诉只会买买买

20万彩礼娶回个“祖宗”!班不上、活不干,一网友哭诉只会买买买

火山詩话
2026-03-21 09:38:42
4000吨稀土被转运美国?大陆停供台湾稀土!台学者:不如直接统一

4000吨稀土被转运美国?大陆停供台湾稀土!台学者:不如直接统一

小舟谈历史
2026-03-19 17:27:44
天大的讽刺!直到释永信被公诉后,才知道她有多让人敬佩

天大的讽刺!直到释永信被公诉后,才知道她有多让人敬佩

冒泡泡的鱼儿
2026-03-22 03:09:47
曝光侵华日军罪证被威胁后续:已报警立案,看完让人解气

曝光侵华日军罪证被威胁后续:已报警立案,看完让人解气

乐天闲聊
2026-03-20 02:35:33
西班牙民调支持率逼近19%,青年倒向威权,民主承诺落空

西班牙民调支持率逼近19%,青年倒向威权,民主承诺落空

光辉与阴暗
2026-03-21 11:21:41
打了6场又伤了! 本赛季最荒唐的交易,用顶级天赋换玻璃人球星

打了6场又伤了! 本赛季最荒唐的交易,用顶级天赋换玻璃人球星

你的篮球频道
2026-03-21 11:36:53
怪不得腿脚有劲了!原来是常吃这菜,硒是洋葱50倍,肝脏也跟着好

怪不得腿脚有劲了!原来是常吃这菜,硒是洋葱50倍,肝脏也跟着好

美食店主
2026-01-15 07:11:12
大排长龙,番禺街坊大量涌入!师傅:6点半就开门了,手没停下来过

大排长龙,番禺街坊大量涌入!师傅:6点半就开门了,手没停下来过

番禺台
2026-03-21 00:07:54
2026-03-22 04:56:49
InfoQ incentive-icons
InfoQ
有内容的技术社区媒体
12188文章数 51814关注度
往期回顾 全部

科技要闻

宇树招股书拆解,人形机器人出货量第一!

头条要闻

伊朗发射3800公里射程的导弹 最令美军战栗的细节披露

头条要闻

伊朗发射3800公里射程的导弹 最令美军战栗的细节披露

体育要闻

谁在决定字母哥未来?

娱乐要闻

田栩宁终于凉了?出轨风波影响恶劣

财经要闻

通胀警报拉响,加息潮要来了?

汽车要闻

小鹏汽车2025年Q4盈利净赚3.8亿 全年营收767亿

态度原创

教育
手机
数码
公开课
军事航空

教育要闻

南师附中举行2026年31公里步行者行动

手机要闻

终端市场集体喊“涨” 手机面板持续走“跌”

数码要闻

炸锅!国产存储芯片再突破!手机固态价格大跳水,内存自由要来了

公开课

李玫瑾:为什么性格比能力更重要?

军事要闻

特朗普:正考虑逐步降级对伊朗的军事行动

无障碍浏览 进入关怀版