好久没折腾 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 | /* |
cgo 编译器会生成这样的 wrapper 函数:
1 | //go:cgo_unsafe_args |
重点在于,虽然参数有 4 个,但是函数体中只使用了 p0 这一个。
导致编译器 SSA 推导优化之后,后面 3 个都是 non-alive 的了,也就在 stackmap 中被标记为 scalar 了,从而在 copystack 中,后面几个指针值就没有被正确处理了
修复方案
知道了原因,其实修复也比较简单了,最早想的是直接用 runtime.Keepalive
,不过生成的 cgo wrapper 包里不能用 runtime 包
最后是参考 _Cgo_use
搞了 _Cgo_keepalive
,本质上也还是欺骗下 golang 编译器,让它认为后面的参数是有用的,也就不会被分析为 non-alive 了
最终效果,就是生成了这样的 wrapper 函数:
1 | //go:cgo_unsafe_args |
是的,多了一些实际上不会执行的 _Cgo_keepalive
的函数调用
shrinkstack
上面分析的是扩栈时的问题,那会不会这种情况呢:
从 Go 进入 C 之后,执行 C 代码的时候,Go runtime 来了个 GC,对 Goroutine 进行缩栈操作呢?
答案是不会的,这个倒是最早在 提案 里就有讨论过的,这种场景下,Goroutine 不会执行 shrinkstack
,所以也是安全的
最后
PR 是修复了一版,也请崔老师 trybot 跑了 CI 了,应该问题不大了
不过,Go 1.23 是赶不上了,估计也只能等 Go 1.24 了
不得不说,还是得多谢在 boringcrypto 中尝鲜这个特性的老哥,要不然这个 bug 确实不太好发现
可以想象一下,在 Envoy 的运行过程中,偶发的 panic,比起纯 Go 的测试环境,那查起来是要酸爽很多的了