去年 10 月搞的提案,最近终于被接受了,第一个被接受的提案,写篇文章记录下的,嘿嘿
故事
去年在搞 MoE,MOSN on Envoy 新架构,折腾了不少 cgo。在分析 cgo 实现的时候,发现了一个 GC 相关的优化点,于是就搞了个提案。
10 月份提交的,一直没动静。今年 2 月开始活跃了,经过简单几轮讨论,就被接受了,整体还算比较顺利的。
之所以会比较顺利,我估计也是搭上了 Go team 的便车,看起来是他们想优化一下 escape analysis,这个提案刚好可以 match,给 escape analysis 更多的信息。
场景
我们知道 C 函数只有一个返回值,Go 调用 C 的时候,如果需要多个返回值,那么 C 函数如何设计呢?
首先,我们希望 Go 和 C 之间的交互足够简单,每次调用都是独立的。比如,我们并不希望在 C 侧申请内存,然后给 Go 返回内存指针。因为这样的话,我们还需要显式释放内存。
那么,最好的办法,就是按照 C 的套路玩,将 Go 对象内存指针,通过参数传给 C,在 C 里给 Go 的内存对象赋值。比如,在 Envoy Go 扩展里,我们就是这么玩的,可以看这个简单的示例:
1 | func (c *httpCApiImpl) HttpGetIntegerValue(r unsafe.Pointer, id int) (uint64, bool) { |
这里,我们将一个 uint64 内存对象的地址传给 C,是不是看起来比较清爽了呢。
问题
单从 Go 代码来看,value 是可以放在 stack 上的,但是,由于有了 cgo 调用,目前的实现,会将 value escape 到 heap 上,这会加重 GC 负载。
尤其在 MoE 这种网关场景中,这种代码是跑在请求处理的热路径上的,并且 Go 代码中可能会比较频繁的进行这类调用,也就是有很多这类 GC 对象 escape 到 heap 中,因此造成的 GC 开销,应该也是不小的。
实现
为了确保总是会 escape,cgo compiler 会生成的这样的 Go 包裹代码:
1 | func _Cgo_use(interface{}) |
目的是,欺骗 escape analysis,如果 p0 是指针,则总是会逃逸。
并且,由于 _Cgo_always_false
总是为假,在编译优化阶段,这个分支又被优化掉了。
原因
为什么有了 cgo 调用,就需要 escape 呢?
Go stack
这里需要先简单介绍一下 Go stack:
目前版本里,Go stack 大小是可变的,而且是连续内存,当生长/缩小的时候,会重新申请内存段,将老的内存从 old stack copy 到 new stack。
并且 copy 过程中会进行比较复杂的栈上指针映射转换,也就是说,stack 上也可以有指针变量指向 stack 上的地址。
但是,如果指针变量已经传给 C,那是没有办法做指针映射转换了,也就是栈发生移动的时候,C 拿到的地址就是非法的了。
时机
那什么情况下,会出现呢?
最开始想到的是,在进入 C 之后,如果 Go 发生了 GC,可能会触发缩栈,但是后来仔细看代码,进入 C 之后,缩栈是被禁掉了的。
另外一个是,不太常用的场景,C 如果又回调了 Go,那么在 Go 里面是可以伸缩栈了,如果在回到 C,此时 C 中的地址就是非法的了。
解决办法
增加 annotation,让编译器感知不需要 escape,不生成 _Cgo_use
就可以了。
一开始我提的是 go:cgo_unsafe_stack_pointer
,Go team 觉得不好,最后是 rsc 大佬提的:#cgo noescape/nocallback
。
感兴趣的话,可以看 proposal 的讨论:
https://github.com/golang/go/issues/56378
最后
提案是接受了,实现还是有些工作量的,主要是这个 annotation 是放在 C code 里的,目前 Go 对于 C 代码并没有 parser。
剩下的部分,应该是比较简单的了。
后面有空在搞了。