众所皆知,「诸神之战」有云(宇宙条的梗):
(这图真实得都包浆了)
于是我们有了 Sonic[1],用 JIT 黑魔法实现的、宇宙条里最快的 Go JSON 编解码器;后来又有了 Frugal[2],也是用 JIT 黑魔法实现的、同样性能炸裂的 Go Thrift 编解码器。故事就发生在 Frugal 这里。
- 起锅 -
来自广告业务的胡同学遇到一个奇怪的现象,进程在启动时出现 fatal error:
found bad pointer in Go heap (incorrect use of unsafe or cgo?)
看 stack 是 Go Runtime 在 GC 时发现了一个无效的指针。
胡同学在自查使用了 unsafe 的代码后没有解决,于是提了个 oncall 给 Go 语言组的王同学(注:字节内专门研究、优化 Go 语言的组)。
通过 pod 的这段错误信息:
runtime: pointer 0xc09e57b5b0 to unused region of span span.base()=0xc077f5a000 span.limit=0xc077f5c000 span.state=1
runtime: found in object at *(0xc056ff0000+0x8)
object=0xc056ff0000 s.isGab()=false s.base()=0xc056ff0000 s.limit=0xc05700e000 s.spanclass=0 s.elemsize=122880 s.state=mSpanInUse
*(object+0) = 0x0
*(object+8) = 0xc09e57b5b0 <==
*(object+16) = 0x0
...(省略)...
*(object+112) = 0x0
*(object+120) = 0xc0b077aeb8
*(object+128) = 0x0
...(省略)...
王同学根据「该对象大小为 122880 字节」,从 heap profile 匹配到了 frugal 的 `encoder.RuntimeState` 这个类型(大小一致),且该类型第一个成员是 `[1024]StateItem`,而 StateItem 是 112 字节大小,与dump 出来的模式一致,因此有理有据地把锅甩了过来。
- 接锅 -
这种锅实在是有点烫 —— 因为出错的位置不是在具体的某行代码,而是因为某处把内存搞错了,延后到 GC 时才发现,所以光是找现场就够吃一壶的。
好在我们已经有了另一个 frugal case 的处理经验(池化管理对象的使用不当,导致异常数据)—— RuntimeState 也是池化管理的,且在代码里没有直接找到 reset 该对象的地方,因此有理有据地怀疑又是类似问题。
于是我 push 了一个去掉该对象池的分支,让胡同学 replace 为这个分支,编译发布试试看。
胡同学表示 ,只是要等双十一的封禁窗口过去才能尝试,于是顺利把这锅给按下去了……一段时间。
- 回锅 -
一周后,胡同学所在团队的伏同学等不及了。
伏同学用该分支发布后,发现问题并没有解决。
由于每次崩溃的现象都一致,都是在这个 122880 字节的对象 offset+8 处有个无效指针,所以大概率甩不出去这锅,只能老老实实啃这硬骨头了。
一个思路是尝试在非生产环境复现该问题,然后想办法缩小问题的 scope。我和伏同学一起尝试了一些方法,例如从线上导流量到预发布环境,希望复现该问题,但并未成功。
另一个思路是拿到事故现场的更多信息,看看能不能找到新的线索。在伏同学的配合下,我们通过设置如下环境变量:
GOTRACEBACK=crash
在该问题再次复现后,捕获到了一个 3.4GB 的 coredump,然后用 dlv 打开:
$ dlv core elf.xxx core.xxx
(dlv) goroutines
Goroutine 1 - User: /opt/pkg/mod/github.com/cloudwego/kitex@v0.7.2/server/server.go:508 ...
...(省略)...
Goroutine 30151 - User: /opt/pkg/mod/github.com/bytedance/gopkg@v0.x.x/util/gopool/worker.go:41 ...
[1053 goroutines]
这进程刚启动就有几万个 goroutine 了,而且有 1000 多个还活着。
只好把它们都 dump 到文件中,vim 打开,通过正则逐批删掉看起来毫无关系的 goroutine(例如 database、redis 等等),在剩下几十个的时候,终于看到希望了:
Goroutine 5461 - User: /opt/pkg/mod/github.com/chenzhouyu/iasm@v0.9.1/x86_64/operands.go:99 github.com/chenzhuoyu/iasm/x86_64.(*Program).Assemble (0xa423309)
(早知道这么直白,直接 grep 了 )
iasm 是 Go 的汇编器,可以把 Go 汇编编译成机器码,正是 sonic 和 frugal 的 JIT 地基(项目作者 zhuoyu 大佬就是 sonic 和 frugal 之父了)。
于是我们切到 goroutine 5461 继续排查:
(dlv) goroutines 5461
(dlv) stack
0 0x0000000008fc153f in runtime.asyncPreempt2
at /opt/tiger/tango1_19_stretch/src/runtime/preempt.go:310
...(以下省略, 完整版详见下附 gist 链接)...
https://gist.github.com/felix021/d9e987e9cfb383aebaecbaacb81c9065
总的来说 stack frame 分析主要是体力活,比较琐碎,就不过多展开了,这里列一些结论:
# frame 26:[kitex] rpctimeout.go:131
这是 Kitex Client 的超时控制中间件,说明不是该服务在接收请求时崩溃的,而是服务内客户端发出的请求导致
之前的复现尝试,因为测试环境不能把请求继续发给 upstream 服务,通过一些方式掐掉了这部分请求,因此没能成功
通过 args 确认了具体是由于哪个结构体导致的崩溃,并且用这个结构体复现了这个异常 case
# frame 14: [frugal] encoder.go:127
119 行:构造了 JIT 编码时需要的 RuntimeState 对象(变量名 rst)
rst 的地址为 0xc056ff0000,与 fatal 错误信息里那个 122880 字节对象的地址一致
rst.St[0].Wp 值为 0xc09e57b5b0 ,就是 offset+8 里那个无效的指针
120行:解开传入的 interface{},获得待编码对象(efv.Value),以便向下透传其指针
127行:由于传入的对象不是指针类型,frugal 会取 &efv.Value 向下透传(形式上一致了,下一层得到的总是指针)
这一层获得的 &efv.Value 值为 0xc057ebf5b0
# frame 13: [frugal] encoder.go:53
从 JIT 编译缓存里获得对应的编码器(enc)
调用 enc 对入参 p (待编码对象的指针)进行编码
p 的值为 0xc09e57b5b0,与 rst.St[0].Wp 相同
到这一步,结果就呼之欲出了:frame 13 拿到了错误的对象指针。
- 打破砂锅 -
从前面截图的代码来看,frame 14(EncodeObject 方法)在 127 行取出并传给了 frame 13(encode方法)的对象指针是:
rt.NoEscape(unsafe.Pointer(&efv.Value))
其中 `unsafe.Pointer(..)` 只是做了个类型转换,问题的嫌疑落在了 `rt.NoEscape`:
// NoEscape hides a pointer from escape analysis
func NoEscape(p unsafe.Pointer) unsafe.Pointer {
x := uintptr(p)
return unsafe.Pointer(x ^ 0)
}
可以看到,它的输入是一个 unsafe.Pointer 指针,做了一些等价变换以后,又原样返回了;实际上在内联以后,这个函数并不会产生额外的指令。
但是它欺骗了 Go 编译器的逃逸分析:本来我们对 efv.Value 取了指针,efv 就应当被分配在堆上,但因为这个骚操作,Go 选择选择了将它分配在栈上。
那么栈上的对象什么时候地址会变化呢?当栈空间不足需要扩大,但是当前位置的 capacity 不够,必须整体移动到新位置的时候。例如 frame 26 发现的这个待编码数据,其类型嵌套深度达到 11 层;层次越深,就有越大的概率触发。
这正是 Go 需要逃逸分析的最主要原因(之一?):保证被取了地址的对象不被分配在栈上,stack relocate 之后,不影响程序的正确性。
但 frugal 并没有遵循这个约定,而是选择了避开逃逸分析;当栈空间被移动后,frame 13 得到的这个地址实际上已经失效了,Go GC 扫描到这个对象时无法处理,整个进程也就只能崩溃了。
- 补锅 -
在咨询 zhuoyu 后得知,当初这么设计的原因是,希望减少一次堆上的内存分配(即 120 行的 efv),因为这会显著降低 frugal 编码小对象的性能。根据 zhuoyu 的建议,我们在 RuntimeState 里新增了一个 unsafe.Pointer 用于储存这个地址,这样就能和 Runtime 同时分配,既不会多一次分配,也能避免地址失效的问题。
修改后的代码如下:
感兴趣的小伙伴可以到 github 上查看 frugal 的 Pull Requests #33[4];里面新增了一个 unit test ,想看现场的话,可以在旧版本运行这个 test 抓个 core 看看。
跑了下性能测试,对比修复前、简单修复(去掉 rt.NoEscape)和最终版:
可以看到,在小对象的编码性能上,多一个 mallocgc 的性能确实非常大。
目前该 fix 已被合入,并发布了新版本,欢迎需要 Thrift 编解码的项目入坑,具体介绍和使用可以点击「阅读原文」查看 frugal 文档[4]。
Frugal 除了有显著高于 Thrift 官方编解码器的性能,还有一个优点是不需要生成 Go 的编解码代码,能大幅提高 IDE 的加载和代码的编译速度。
例如内部某项目使用 Frugal 的 slim 模板后,生成代码的行数从 117 万行缩减到 14 万行(-88%),代码的编译时间也从 5m45s 缩减到 2m10s(-62%)。
(灵魂拷问不能省,你猜是啥?)
最后,Frugal 也是 CloudWeGo[5] 旗下的一款产品,欢迎大家关注 CloudWeGo 的微信公众号:
参考链接:
[1] Sonic: https://github.com/bytedance/sonic
[2] Frugal: https://github.com/cloudwego/frugal
[3] PR #33: https://github.com/cloudwego/frugal/pull/33
[4] Frugal 文档: https://www.cloudwego.io/zh/docs/kitex/tutorials/advanced-feature/codec_frugal/
[5] CloudWeGo: https://www.cloudwego.io/