十一期间,对 Envoy Go 扩展的 cgo API 进行了一波调整。
我们之前是直接将 Go 里面 string
,slice
等类型的内存地址传给 C,虽然是足够高效了,但是呢,这种是不能开启 cgocheck
的。
上一次还搞了个提案,想让 Go 开一个口子,可以在函数级别关闭 cgocheck
,但是,被教育了,哈哈
所以,咱们还是老老实实的,搞成 cgocheck
安全的方式。今天这篇文章,就来分享下实现方式,如果有不对的地方,欢迎拍砖。
说明
先说明几点:
这里的写法,影响的是每调用 10-100ns 这个量级的性能
如果不是足够广泛使用的代码,不关心这点性能,大可忽略这些奇技淫巧
有些方式,依赖较高的 golang 版本,如果想实际应用,越新的版本越好
最好是用 1.22,是的,下一个要发布的版本
这里假设我们对 C 函数也有完全的掌控力,可以按照我们期望的方式来随意调整
如果是现有的 C 函数,不能调整,那就是另一回事了
还债
早在去年,刚开始分享 cgo 的时候,就有人在抱怨,cgo 需要内存拷贝,当时回复的是,以后会分享,这次也算还债来了。
场景
接下来,就用几个典型的示例来说明。
将一个 Go string 传给 C
常规写法
1 | /* |
这是常规的搞法,在 Go 侧调用 C 的 malloc
申请堆上内存,然后将 Go string 拷贝到 C 内存,最后才将 C 内存传给 C。
这种写法,对 C 程序来说,是最友好的,标准的 C 字符串,以及完整可控的内存生命周期,只要 Go 还没调用 C.free
,C 侧就可以一直使用。
不过,这里需要两次 Go 调 C 的 cgo 调用,也就是 malloc
和 free
的调用;以及一次内存拷贝,所以性能并不是最优解。
优化写法
1 | /* |
在 Envoy Go 扩展中,我们将 Go string 实际的 data 指针,以及字符串长度传给了 C,在 C 直接读取 Go string 的内存。
整个过程没有内存拷贝,甚至,在由于 cgo compiler 的优化,也没有 cgocheck
的检查。
注意:noescape
和 nocallback
需要 Go1.22 版本才支持,可以避免将 data 强制 escape 到堆上,具体见这篇 cgo 内存优化
不过,这里也有一定的局限性,也就是不能灵活控制内存的生命周期,C 侧一定不能保存 Go string 的内存地址,因为这个 C 函数返回之后,Go string 的内存就可能被释放了。
好在通常来说,这已经很足够了,所以,通常情况下,这种写法是最高效的。
从 C 获取一个未知长度的 string
1 | /* |
首先,我们直接获取 C 侧内存中,字符串的地址,以及长度
因为 C 只能有一个返回值,所以我们传了两个变量地址,让 C 来写入
然后,根据地址和长度,构建
unsafe string
,此时引用的是 C 内存如果你确定在使用这个 Go string 的时候,这个 C 内存不会被释放,那么使用这个 unsafe string 也是安全的
如果不能的话,需要在 Go 侧 clone 一份,新的 Go string 使用的 Go GC 的内存了,C 内存可以被释放了。
不过,这里需要注意的是,在 Go clone 完成之前,C 侧字符串内存是不能释放的
从 C 获取一个已知长度的 string
1 | /* |
如果是已知的长度,我们可以在 Go 侧需要分配好内存空间,将 Go 内存地址传给 C,在 C 侧完成内存拷贝
这样 C 侧的内存管理就很简单了,不需要在 clone 之前还得保留内存
传一批 Go string 给 C
1 | /* |
这里入参是一个 map,我们没办法直接把 Go map 传给 c 来用
- 先转成一个 slice,也就是 C 认识的数组
- 由于
cgocheck
默认开启,我们需要将 Go string 中的 data 指针给 pin 住
cgocheck 开启的模式下,相对于使用 C.CString
拷贝内存,使用 runtime.Pinner
是性能更优的方式。
不过,需要注意的是,Go 1.21 的 runtime.Pinner
只能 pin Go GC 的内存,如果 Go string 是常量,那么 pin 会 panic。但是,我们作为底层库的实现者,并不知道这个 Go string 是常量还是变量。
也就是说,只有 Go 1.22 才能安全的使用 runtime.Pinner
,具体见 这个补丁。
所以,Envoy Go 扩展的实现里,我们还是用的 C.CString
,毕竟 Go 1.22 还没发布…
PS:如果关闭 cgocheck
,就不需要 pinner
,性能可以有成倍提成,但是,没有 pinner
,以后实现 moving GC 了就可能有风险,如果不是特别关注这些性能,最好还是留着 pinner
。
总结
- 可以将 Go 内存传给 C,但是,最好这个内存中,不要再包含指针
- 如果场景,必须得包含指针,那就优先考虑
runtime.Pinner
- C 函数的函数,尽量是用类型明确的指针,比如
int *
,而不是void *
,这样可以 cgo compiler 有可能会帮我们优化
最近翻了翻 cgo compiler 在生成 cgocheck
的代码,也是有考虑优化的,并不是所有内存都会执行 cgocheck
检查,而是会根据 C 函数的参数类型选择性的进行检查。
所以,实际上,也是有一些骚操作,可以欺骗 cgo compiler 来绕过 cgocheck
,不过嘛,咱们还是不这么玩了,哈哈