0%

如何写出高效的 cgo 代码

十一期间,对 Envoy Go 扩展的 cgo API 进行了一波调整

我们之前是直接将 Go 里面 stringslice 等类型的内存地址传给 C,虽然是足够高效了,但是呢,这种是不能开启 cgocheck 的。

上一次还搞了个提案,想让 Go 开一个口子,可以在函数级别关闭 cgocheck,但是,被教育了,哈哈

所以,咱们还是老老实实的,搞成 cgocheck 安全的方式。今天这篇文章,就来分享下实现方式,如果有不对的地方,欢迎拍砖。

说明

先说明几点:

  1. 这里的写法,影响的是每调用 10-100ns 这个量级的性能

    如果不是足够广泛使用的代码,不关心这点性能,大可忽略这些奇技淫巧

  2. 有些方式,依赖较高的 golang 版本,如果想实际应用,越新的版本越好

    最好是用 1.22,是的,下一个要发布的版本

  3. 这里假设我们对 C 函数也有完全的掌控力,可以按照我们期望的方式来随意调整

    如果是现有的 C 函数,不能调整,那就是另一回事了

还债

早在去年,刚开始分享 cgo 的时候,就有人在抱怨,cgo 需要内存拷贝,当时回复的是,以后会分享,这次也算还债来了。

评论

场景

接下来,就用几个典型的示例来说明。

将一个 Go string 传给 C

常规写法

1
2
3
4
5
6
7
8
9
10
11
12
13
/*
void readCString(const char* s) {
printf("%s", s);
}
*/
import "C"

func passGoStringToC(s string) {
cs := C.CString(s)
defer C.free(unsafe.Pointer(cs))

C.readCString(cs)
}

这是常规的搞法,在 Go 侧调用 C 的 malloc 申请堆上内存,然后将 Go string 拷贝到 C 内存,最后才将 C 内存传给 C。

这种写法,对 C 程序来说,是最友好的,标准的 C 字符串,以及完整可控的内存生命周期,只要 Go 还没调用 C.free,C 侧就可以一直使用。

不过,这里需要两次 Go 调 C 的 cgo 调用,也就是 mallocfree 的调用;以及一次内存拷贝,所以性能并不是最优解。

优化写法

1
2
3
4
5
6
7
8
9
10
11
12
/*
#cgo noescape passGoString
#cgo nocallback passGoString
void passGoString(void *str, int len) {
// read memory in the pointer *str
}
*/
import "C"

func passGoStringToC(str string) {
C.passGoString(unsafe.Pointer(unsafe.StringData(str)), C.int(len(str)))
}

在 Envoy Go 扩展中,我们将 Go string 实际的 data 指针,以及字符串长度传给了 C,在 C 直接读取 Go string 的内存。

整个过程没有内存拷贝,甚至,在由于 cgo compiler 的优化,也没有 cgocheck 的检查。

注意:noescapenocallback 需要 Go1.22 版本才支持,可以避免将 data 强制 escape 到堆上,具体见这篇 cgo 内存优化

不过,这里也有一定的局限性,也就是不能灵活控制内存的生命周期,C 侧一定不能保存 Go string 的内存地址,因为这个 C 函数返回之后,Go string 的内存就可能被释放了。

好在通常来说,这已经很足够了,所以,通常情况下,这种写法是最高效的。

从 C 获取一个未知长度的 string

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
/*
#cgo noescape getGoString
#cgo nocallback getGoString
void getGoString(unsigned long long *data, int *len) {
*data = (unsigned long long)(&"foo");
*len = 3;
}
*/
import "C"

func getGoStringFromC() string {
var data C.ulonglong
var len C.int
C.getGoString(&data, &len)
unsafeStr := unsafe.String((*byte)(unsafe.Pointer(uintptr(data))), int(len))
return strings.Clone(unsafeStr)
}
  1. 首先,我们直接获取 C 侧内存中,字符串的地址,以及长度

  2. 因为 C 只能有一个返回值,所以我们传了两个变量地址,让 C 来写入

  3. 然后,根据地址和长度,构建 unsafe string,此时引用的是 C 内存

    如果你确定在使用这个 Go string 的时候,这个 C 内存不会被释放,那么使用这个 unsafe string 也是安全的

  4. 如果不能的话,需要在 Go 侧 clone 一份,新的 Go string 使用的 Go GC 的内存了,C 内存可以被释放了。

不过,这里需要注意的是,在 Go clone 完成之前,C 侧字符串内存是不能释放的

从 C 获取一个已知长度的 string

1
2
3
4
5
6
7
8
9
10
11
12
13
/*
void getGoStringByLen(void *data, int len) {
memcpy(data, "foo", len);
}
*/
import "C"

func getGoStringFromCByLen(len uint64) string {
slice := make([]byte, len)
str := unsafe.String(unsafe.SliceData(slice), len)
C.getGoStringByLen(unsafe.Pointer(unsafe.StringData(str)), C.int(len))
return str
}

如果是已知的长度,我们可以在 Go 侧需要分配好内存空间,将 Go 内存地址传给 C,在 C 侧完成内存拷贝

这样 C 侧的内存管理就很简单了,不需要在 clone 之前还得保留内存

传一批 Go string 给 C

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
/*
void passGoStrings(void *data, int len) {
_GoString_* s = data;
for (int i = 0; i < len; i++) {
printf("str: %.*s\n", s[i].n, s[i].p);
}
}
*/
import "C"

func passGoStringMapToC(m map[string]string) {
s := make([]string, 0, len(m)*2)
var pinner runtime.Pinner
defer pinner.Unpin()
for k, v := range m {
s = append(s, k, v)
pinner.Pin(unsafe.StringData(k))
pinner.Pin(unsafe.StringData(v))
}
C.passGoStrings(unsafe.Pointer(unsafe.SliceData(s)), C.int(len(s)))
}

这里入参是一个 map,我们没办法直接把 Go map 传给 c 来用

  1. 先转成一个 slice,也就是 C 认识的数组
  2. 由于 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

总结

  1. 可以将 Go 内存传给 C,但是,最好这个内存中,不要再包含指针
  2. 如果场景,必须得包含指针,那就优先考虑 runtime.Pinner
  3. C 函数的函数,尽量是用类型明确的指针,比如 int *,而不是 void *,这样可以 cgo compiler 有可能会帮我们优化

最近翻了翻 cgo compiler 在生成 cgocheck 的代码,也是有考虑优化的,并不是所有内存都会执行 cgocheck 检查,而是会根据 C 函数的参数类型选择性的进行检查。

所以,实际上,也是有一些骚操作,可以欺骗 cgo compiler 来绕过 cgocheck,不过嘛,咱们还是不这么玩了,哈哈