0%

cgo 优化第二弹:内存优化

第二个 cgo 优化的补丁 合并 Go 官方仓库了,哈哈,照例写个文章吹吹水 :)

嗯,之前一波三折的 CPU 耗时优化 算第一弹,这个就算第二弹了~

背景

这个内存优化的背景,之前也写过一篇,记一个 Go 编译器优化提案,详细描述了为什么要搞这个优化。

简单说就是,希望 Go 对象传给 C 的时候,不要强制把 Go 对象逃逸到堆上。因为一个对象能保留在栈上,性能开销是更低的。

具体的做法呢,提供了两个 annotation 标记:

  1. #cgo noescape functionName

    表示传给 functionName 这个 C 函数的 Go 对象,不需要强制逃逸到堆上

  2. #cgo nocallback functionName

    申明 functionName 这个 C 函数,不会有回调 Go 函数,此时 runtime 会加入一个检查,如果实际检测到这个 C 函数回调了 Go 函数,那就直接 panic

想了解详情的朋友,可以回看之前的文章。今天这篇呢,主要记录下实现机制。

实现机制

首先,交代一个背景知识,cgo是 golang 自带的一个小编译工具,比如,我们写的 Go 调用 C 的代码,会先被 cgo预编译一次,然后才用标准的 Go 编译器编译。

感兴趣的朋友,可以移步这篇 从编译器视角看 cgo

了解这个背景之后,相对来而言,这个补丁逻辑是比较简单的了:

  1. 提取 annotation 信息
  2. 跳过生成 _Cgo_use 代码,这样 noescape 的效果就实现了
  3. 从 Go 进入 C 的时候,在 goroutine 上打个标,从 C 调用 Go 函数的时候,检查这个标,这样就可以实现 nocallback的运行时检查

简单来说,这个补丁的改动,基本都在 cgo 这个子编译工具里。

提取 annotation

cgo 这个编译工具,逻辑上来说,也是比较简单的,因为 Go 语法解析生成 AST,还是复用的标准 Go 的那一套。

并且,对于 Go 源码中,在注释中申明的 C 代码,也并没有一个语法解析器,只是简单的按照文本匹配的方式在处理。

至于 C 代码的合法性,以及 C 代码中的函数等信息,Go 直接调用 C 编译器来编译,然后解析 C 编译器产生 dwarf 信息来提取,也不失为一种好的选择。

所以,提取 annotation,我们也只是按照文本匹配的方式处理,找到对应的地方,实现就很简单了。

跳过 _Cgo_use

这个是在 cgo 编译器的输出阶段,根据提取的 annotation 信息,选择性的跳过即可。

不过,这地方有个小插曲,当时跳过了 _Cgo_use 之后,发现还是会强制逃逸到堆上。

经过一番分析,发现还有一个地方,也会触发强制逃逸:func _cgoCheckPointer(interface{}, interface{})

这个跟 _Cgo_use(interface{}) 是一个效果,指针类型的对象转为 interface{} 类型的时候,就会逃逸。

经过 Ian Lance 大佬指点,为 _cgoCheckPointer 加上了 //go:noescape,这事才算了了。

关于 //go:noescape 其实也值得说道说道,不过今天就不聊了,以后有空再说吧。

运行时 nocallback 检查

如果对 C 调用 Go 和 Go 调用 C,这两个 cgo 的调用流程比较清楚的话,这个实现也就比较简单了。

主要是实现了一个 cgoNoCallback(v bool) 的 runtime 函数,通过它来设置 goroutine 中的nocgocallback 的标

  1. 在 Go 调用 C 的时候,调用 _Cgo_no_callback(true)

  2. 从 C 返回 Go 的时候,_Cgo_no_callback(false)

检查则是在 cgocallbackg 中完成,这是 C 调用 Go 中的一个入口函数。

如果对 cgo 调用流程不够了解,且有兴趣的话,可以移步这两篇,C 调用 GoGo 调用 C

效果

搞了一个 benchmark,对于将一个 Go string 传入 C 的场景,87 ns vs 61 ns 提升了 20+ ns,这个效果比我预想的略好一些,哈哈。

1
2
3
4
BenchmarkCgoCall/string-pointer-escape
BenchmarkCgoCall/string-pointer-escape-12 67731663 87.02 ns/op
BenchmarkCgoCall/string-pointer-noescape
BenchmarkCgoCall/string-pointer-noescape-12 99424776 61.30 ns/op

至于在 MoE 框架中的真实效果,后面得空在 Envoy Go 中集成了这个优化,测试一把之后,再来吹水了。

值得一提的是,这个优化的是 Go 调用 C 的场景,也就是当 Go 代码中有比较多的调用,比如 getHeadersetHeader 这种操作的时候,效果才出得来。如果是简单的 Passthrough 场景的压测,那应该是看不出来区别的。

也就是,复杂的场景,交互比较多的场景,效果会更明显。

彩蛋

在实现的过程中发现,对于 Envoy Go 目前依赖 GODEBUG=cgocheck=0 这个环境变量来关闭 cgocheckpointer 的坑,或许有了新的更好的解法:

#cgo nocheckpointer functionName 申明这个 C 函数的参数,不需要 cgocheckpointer,好处是:

  1. 可以在 C 函数级别指定生效,影响域足够小
  2. 可以写在 Envoy Go 的源码里,完全不需要用户关心
  3. 实现也简单,跳过 _cgoCheckPointer 的生成就行

以后有空了,可以试试再搞个 proposal,最近太卷了,没得空玩了~

最后,如果你对 cgo 也感兴趣,欢迎一起交流~