文|朱德江(GitHub ID:doujiang24)
MOSN 项目核心开发者蚂蚁集团技术专家
专注于云原生网关研发的相关工作。
本文 224 字 阅读 8 分钟
PART. 1
引言
在上回的文章《Go 内存泄漏,pprof 够用了么?》中说到,从一个 core 文件生成内存引用关系火焰图时,虽然可以从 core 文件中读到所有的内存对象,但是并不知道它们的类型信息。
这是因为 Go 作为静态类型语言,在运行时,内存对象的类型是已知的。也就是说,并不需要想动态类型语言那样,为每个内存对象在内存中存储其类型信息 (有点例外的是 interface) 。
比如这个 Go 语言例子:
type Foo struct { a uint64 b int64}
func foo(f *Foo) int64 { return f.b}
Foo
函数在使用 f
这个指针时,并不需要判断其类型,直接读一个带偏移量地址就能得到 f.b
,也就是一条指令:mov rax, qword ptr [rax + 8]
,就是这么简单直接。
再看 Lua 语言这个例子:
function foo(f) return f.bendfoo({ b = 1 })
Foo
函数在执行的时候,首先得判断 f
的类型,如果是 table
,则按照 key 取 b
的值;如果不是,则抛运行时 error。
能够运行时判断 f
的类型,是因为 Lua 中变量是用 TValue
来表示的,这个 TValue
结构中,就有一个信息用来存储变量类型。
PART. 2
逆向类型推导
逆向类型推导的逻辑是:根据已知内存的类型信息,推导被引用的内存对象的类型信息。
比如这个例子:
type Foo struct { a uint64 b int64}type Bar struct { f *Foo}var b Bar
如果我们知道了 b
的类型是 Bar
,那么 b
中第一个 field 指向的内存对象,就是 Foo
类型了 (前提是合法的内存对象地址) 。
既然存在推导,那我们怎么知道一些初始值呢?
一共有两类来源:
1.全局变量;
2.协程中每一帧函数的局部变量。
PART. 3
全局变量
Go 在编译的时候,默认会生成一些调试信息,按照 DWARF 标准格式,放在 ELF 文件中 .debug_*
这样的段里。
这些调试信息中,我们关注两类关键信息:
类型信息: 包括了源码中定义的类型,比如某个 struct 的名字、大小、以及各个 field 类型信息;
全局变量: 包括变量名、地址、类型,调试信息中的、全局变量的地址、以及其类型信息,也就是构成推导的初始值。
函数局部变量,要复杂一些,不过基本原理是类似的,这里就不细说了~
PART. 4
推导过程
推导过程,跟 GC-Mark 的过程类似,甚至初始值也跟 GC-Root 一样。
所以,全部推导完毕之后,GC-Mark 认为是 alive 的内存对象,其类型信息都会被推导出来。
interface
Go 语言中 interface 比较类似动态类型,如下是空接口的内存结构,每个对象都存储了其类型信息:
type eface struct { _type *_type data unsafe.Pointer}
按照类型推导,我们能知道一个对象是 interface{}
,但是其中 Data 指向对象,是什么类型,我们则需要读取 _type
中的信息了。
_type
中有两个信息,对我们比较有用:
1.名字
不过比较坑的是,只存了 pkg.Name
并没有存完整的 Include Path 这个也合理的,毕竟 Go 运行时并不需要那么精确,也就是异常时,输出错误信息中用一下。不过在类型推导的时候,就容易踩坑了。
2.指针信息
具体存储形式有点绕,不过也就是表示这个对象中,有哪些偏移量是指针。
有了这两个信息之后,就可以从全量的类型中,筛选出符合上面两个信息的类型。
通常情况下,会选出一个正确的答案,不过有时候选出多个,仅仅根据这两个信息还不能区分出来,一旦一步错了,后面可能就全推导不出来了。
我们给 Go 官方 Debug 贡献了一个补丁,可以进一步的筛选,有兴趣的可以看 CL 419176[1]。
unsafe.pointer
其实,在上面的 interface 示例中,最根源的原因,也就是 data unsafe.pointer
,这个指针并没有类型信息,只是 interface 的实现中,有另外的字段来存储类型信息。
不过,在 Go Runtime 中还有其它的 unsafe.pointer
,就没有那么幸运了。
比如 map
和 sync.map
的实现都有 unsafe.pointer
,这种就没有办法像 interface
那样统一来处理了,只能 case-by-case,根据 map/sync.map
的结构特征来逆向写死了…
我们给 Go 官方 Debug 贡献了 sync.map
的逆向实现,有兴趣的可以看 CL 419177[2]。
PART. 5
隐藏类型
除了源码中显示定义的类型,还有一些隐藏的类型,比如:Method Value
、
Closure
的实现中,也都是用 struct
来表示的,这些属于不太容易被关注到的“隐藏”类型。
Method Value
在逆向推导中,还是比较容易踩坑的,我们给 Go 官方 Debug 贡献了这块的实现,有兴趣的可以看 CL 419179[3]。
相比 Method Value
这种固定结构的,Closure
这种会更难搞一些,不过幸运的是,我们目前的使用过程中,还没有踩坑的经历。
PART. 6
逆向推导风险
这种逆向推导要做到 100% 完备还是挺难的,根本原因还是 unsafe.pointer
。
在 reflect.Value
中也有 unsafe.pointer
,据我所知,这个是还没有逆向推导实现的,类似的应该也还有其它未知的。
甚至,如果是标准库中的类型,我们还是可以一个个按需加上,如果是上层应用代码用到的 unsafe.pointer
,那就很难搞了。
还有一种可能,推导不出来的原因,就是内存泄漏的来源,我们就碰到这样一个例子,以后有机会再分享~
幸运的是:如果是只是少量的对象没有推导出来,对于全局内存泄漏分析这种场景,通常影响其实也不大。
另外,对于一个对象,只需要有一个路径可以推导出来也就够了。
也就是说,如果一条推导线索因为 unsafe.pointer
断了,如果另外有一个线索可以推导到这个对象,那也是不影响的。因为从 GC root
到一个 GC obj
的引用关系链,可能会不止一条。
PART. 7
小结
Go 虽然是静态类型语言,不过由于提供了 unsafe.pointer
,给逆向类型推导带来了很大的麻烦。好在 Go 对于 unsafe.pointer
的使用还是比较克制,把标准库中常用到的 unsafe.pointer
搞定了,基本也够用了。
理论上来说,逆向推导这一套也适用于 C 语言,只不过 C 语言这种指针漫天飞的,动不动就来个强制类型转换,就很难搞了。
|相关链接|
[1]CL 419176:
https://go-review.googlesource.com/c/debug/+/419176
[2]CL 419177: https://go-review.googlesource.com/c/debug/+/419177
[3]CL 419179: https://go-review.googlesource.com/c/debug/+/419179
了解更多…
MOSN Star 一下✨: https://github.com/mosn/mosn