cgo 内存优化后续 - 修了个 bug

好久没折腾 cgo,上一篇已经是去年了,cgo 内存优化无缘 golang 1.22[1] 中提到,golang 1.23 会合并回来

眼看 golang 1.23 即将 freeze,于是提了个 PR[2],想着开启内存优化

还有 bug

很不幸的是,rsc 说之前 boringcrypto 使用了这个优化,导致了一个 CI 失败[3],需要先修复了

好吧,原来上一篇里有个乌龙,上次我们说,被 revert 的原因是,#cgo 指令的向后兼容性的问题

实际上并不只是这一个原因,而是,确实还有个 bug ...

仔细看了那个 issue,是在 arm64 机器上,并且开启 boringcrypto 特性的时候,才会偶发出现的错误

心想这不会是个 arm64 上的坑吧,难道又要挨个翻 arm64 的指令了...

于是,在阿里云上搞了个 arm64 的机器,发现确实有小概率会测试失败

好吧,能复现就是好的开始,虽然是小概率随机

原因

分析过程就不展开了,有点繁琐,咱们直接说原因

首先,我们这个优化,是让编译器,将内存放到栈上,C 直接使用 Goroutine 栈上的地址,来减少 GC 的开销

然后,问题就是,Gorontine 的栈是会移动的,地址变了,导致 C 使用的地址就是非预期的了

copystack

对于栈移动这种场景,之前也是分析过的,应该是没问题的才对的

因为 runtime 移动栈的操作,也就是 copystack 这个函数,是会处理栈上指针的,让新栈上的指针指向新的地址

stackmap

具体的指针调整,涉及的点还比较多,核心的还是每个栈帧的处理,这里就涉及到 stackmap

大致可以这么理解,每个函数的栈空间是固定的,stackmap 就是描述这个栈空间上对象的信息,比如是否为指针

其中,有一个部分就是存了函数的参数信息,到底是一个 pointer 还是 scalar

这次的问题就出在这里,有些代码上看起来是 pointer 的参数,被编译器认为是 scalar

cgo wrapper

还得先回到 cgo 编译器的实现,比如这样一个 Go 调用 C 的代码

/*
int pointer3(int *a, int *b, int *c, int d) {
 return *a + *b + *c + d;
}
#cgo noescape pointer3
#cgo nocallback pointer3
*/
import "C"

//go:noinline
func testC() {
 var a, b, c, d C.int = 1, 2, 3, 4
 C.pointer3(&a, &b, &c, d)
}

cgo 编译器会生成这样的 wrapper 函数:

//go:cgo_unsafe_args
func _Cfunc_pointer3(p0 *_Ctype_int, p1 *_Ctype_int, p2 *_Ctype_int, p3 _Ctype_int) (r1 _Ctype_int) {
 _Cgo_no_callback(true)
 _cgo_runtime_cgocall(_cgo_cab107a710a2_Cfunc_pointer3, uintptr(unsafe.Pointer(&p0)))
 _Cgo_no_callback(false)
 return
}

重点在于,虽然参数有 4 个,但是函数体中只使用了 p0 这一个。

导致编译器 SSA 推导优化之后,后面 3 个都是 non-alive 的了,也就在 stackmap 中被标记为 scalar 了,从而在 copystack 中,后面几个指针值就没有被正确处理了

修复方案

知道了原因,其实修复也比较简单了,最早想的是直接用 runtime.Keepalive,不过生成的 cgo wrapper 包里不能用 runtime 包

最后是参考 _Cgo_use 搞了 _Cgo_keepalive,本质上也还是欺骗下 golang 编译器,让它认为后面的参数是有用的,也就不会被分析为 non-alive 了

最终效果,就是生成了这样的 wrapper 函数:

//go:cgo_unsafe_args
func _Cfunc_pointer3(p0 *_Ctype_int, p1 *_Ctype_int, p2 *_Ctype_int, p3 _Ctype_int) (r1 _Ctype_int) {
 _Cgo_no_callback(true)
 _cgo_runtime_cgocall(_cgo_cab107a710a2_Cfunc_pointer3, uintptr(unsafe.Pointer(&p0)))
 _Cgo_no_callback(false)
 if _Cgo_always_false {
  _Cgo_keepalive(p0)
  _Cgo_keepalive(p1)
  _Cgo_keepalive(p2)
  _Cgo_keepalive(p3)
 }
 return
}

是的,多了一些实际上不会执行的 _Cgo_keepalive 的函数调用

shrinkstack

上面分析的是扩栈时的问题,那会不会这种情况呢:

从 Go 进入 C 之后,执行 C 代码的时候,Go runtime 来了个 GC,对 Goroutine 进行缩栈操作呢?

答案是不会的,这个倒是最早在 提案[4] 里就有讨论过的,这种场景下,Goroutine 不会执行 shrinkstack,所以也是安全的

最后

PR 是修复了一版,也请崔老师 trybot 跑了 CI 了,应该问题不大了

不过,Go 1.23 是赶不上了,估计也只能等 Go 1.24 了

不得不说,还是得多谢在 boringcrypto 中尝鲜这个特性的老哥,要不然这个 bug 确实不太好发现

可以想象一下,在 Envoy 的运行过程中,偶发的 panic,比起纯 Go 的测试环境,那查起来是要酸爽很多的了

参考资料

[1]

cgo 内存优化无缘 golang 1.22: http://uncledou.site/2023/cgo-memory-optimization-delay/

[2]

PR: https://github.com/golang/go/pull/66879

[3]

CI 失败: https://github.com/golang/go/issues/63739

[4]

提案: https://github.com/golang/go/issues/56378

想要了解Go更多内容,欢迎扫描下方👇关注公众号,扫描 [实战群]二维码  ,即可进群和我们交流~


- 扫码即可加入实战群 -

9b5e12dbc7ef8d204bbf7fb093f4ce87.png

551207ef965a6eda0da2a65e276b4352.png

分享、在看与点赞Go 8906dedf8f5c3111275c395b796aa7fa.gif

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值