0%

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

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

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

还有 bug

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

好吧,原来上一篇里有个乌龙,上次我们说,被 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 的代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
/*
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 函数:

1
2
3
4
5
6
7
//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 函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
//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 进行缩栈操作呢?

答案是不会的,这个倒是最早在提案 https://github.com/golang/go/issues/56378 里就有讨论过的,这种场景下,Goroutine 不会执行 shrinkstack,所以也是安全的

最后

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

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

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

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