好久没折腾 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/