踩坑记:欲速则……达的 Frugal

76e136bdaca1d3f2e603be098a1d688e.jpeg

众所皆知,「诸神之战」有云(宇宙条的梗):

8d4870e0c037124e160b3cb3703fe413.png

(这图真实得都包浆了)

于是我们有了 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 出来的模式一致,因此有理有据地把锅甩了过来。

74498f145e855cb04c786f341c21ac9a.png

- 接锅 -


这种锅实在是有点烫 —— 因为出错的位置不是在具体的某行代码,而是因为某处把内存搞错了,延后到 GC 时才发现,所以光是找现场就够吃一壶的。

好在我们已经有了另一个 frugal case 的处理经验(池化管理对象的使用不当,导致异常数据)—— RuntimeState 也是池化管理的,且在代码里没有直接找到 reset 该对象的地方,因此有理有据地怀疑又是类似问题。

于是我 push 了一个去掉该对象池的分支,让胡同学 replace 为这个分支,编译发布试试看。

胡同学表示 eb246d0ab32bb310c65d3f96641b03f6.png,只是要等双十一的封禁窗口过去才能尝试,于是顺利把这锅给按下去了……一段时间。

9c870aebf11e6e33e6c75ea21e6bc1f7.png

- 回锅 -


一周后,胡同学所在团队的伏同学等不及了。

1db9fafb319fbffeed645aed908efd87.png

伏同学用该分支发布后,发现问题并没有解决。

由于每次崩溃的现象都一致,都是在这个 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 多个还活着。

ee021f450496c1978abdbbe597972a55.png

只好把它们都 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 了 e32a86712450f5b34b7a13d1b075a2b3.png

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

ea82ba34ca9c03bc73dd2f704cd1ee4a.png

  • 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 拿到了错误的对象指针。

13a700a315baf53d6bc2195074bf617e.png

- 打破砂锅 -


从前面截图的代码来看,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 扫描到这个对象时无法处理,整个进程也就只能崩溃了。

dd523ab04463d3e143e5a0cddcab1951.png

- 补锅 -


在咨询 zhuoyu 后得知,当初这么设计的原因是,希望减少一次堆上的内存分配(即 120 行的 efv),因为这会显著降低 frugal 编码小对象的性能。根据 zhuoyu 的建议,我们在 RuntimeState 里新增了一个 unsafe.Pointer 用于储存这个地址,这样就能和 Runtime 同时分配,既不会多一次分配,也能避免地址失效的问题。

修改后的代码如下:

97da94446e1ad7e28623edd862c68644.jpeg

感兴趣的小伙伴可以到 github 上查看 frugal 的 Pull Requests #33[4];里面新增了一个 unit test ,想看现场的话,可以在旧版本运行这个 test 抓个 core 看看。

跑了下性能测试,对比修复前、简单修复(去掉 rt.NoEscape)和最终版:

144047b46ddbb12a6ae0b7129e1b2cec.png

可以看到,在小对象的编码性能上,多一个 mallocgc 的性能确实非常大。

目前该 fix 已被合入,并发布了新版本,欢迎需要 Thrift 编解码的项目入坑,具体介绍和使用可以点击「阅读原文」查看 frugal 文档[4]。

Frugal 除了有显著高于 Thrift 官方编解码器的性能,还有一个优点是不需要生成 Go 的编解码代码,能大幅提高 IDE 的加载和代码的编译速度。

例如内部某项目使用 Frugal 的 slim 模板后,生成代码的行数从 117 万行缩减到 14 万行(-88%),代码的编译时间也从 5m45s 缩减到 2m10s(-62%)。

9a4f22905052bf9868855db532a27e83.jpeg

(灵魂拷问不能省,你猜是啥?)

最后,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/

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
Python官方文档是Python编程语言的官方开发文档,提供了关于Python语法、标准库、模块、函数等方面的详细说明和示例代码。官方文档是Python开发者学习和使用Python的重要参考资料。你可以在官方网站上找到Python的官方文档,也可以在CSDN上下载Python3.8和3.10版本的官方开发文档的HTML版本,以便更方便地查看和学习。\[1\]此外,还有《Python/C API 参考手册》可以供C和C++程序员参考,该手册描述了编写扩展模块和将Python解释器嵌入应用程序中所需的API函数。\[2\]需要注意的是,官方文档并没有完整地介绍每一个功能,但它涵盖了Python中最值得注意的功能,并让你体会到这个语言的风格特色。学习完官方文档,你将能够更好地阅读和编写Python模块和程序。\[3\] #### 引用[.reference_title] - *1* [Python官方开发文档(中文版)](https://blog.csdn.net/baidu_37075689/article/details/124051213)[target="_blank" data-report-click={"spm":"1018.2226.3001.9630","extra":{"utm_source":"vip_chatgpt_common_search_pc_result","utm_medium":"distribute.pc_search_result.none-task-cask-2~all~insert_cask~default-1-null.142^v91^control_2,239^v3^insert_chatgpt"}} ] [.reference_item] - *2* *3* [来了!Python 官方发布整套中文PDF文档(共27本)](https://blog.csdn.net/lemonbit/article/details/117887455)[target="_blank" data-report-click={"spm":"1018.2226.3001.9630","extra":{"utm_source":"vip_chatgpt_common_search_pc_result","utm_medium":"distribute.pc_search_result.none-task-cask-2~all~insert_cask~default-1-null.142^v91^control_2,239^v3^insert_chatgpt"}} ] [.reference_item] [ .reference_list ]

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值