Go 1.21 版本,将增加 runtime.Pinner类型。简单看了下,想要解决的问题,跟我们搞 Envoy Go 扩展的时候,非常接近。
1cgoCheckPointer
得先从 cgo 的一个限制说起:
如果将一个 Go 对象的指针传给 C,那么这个 Go 对象里,是不能再有指针指向 Go 对象的(空指针可以)。
这个检查是在运行时进行的,cgoCheckPointer,如果检查到了,就抛 panic: cgo argument has Go pointer to Go pointer
举个简单的例子,如果我们将一个 Go string 的指针,传给 C,就会触发这个 panic。因为 string 的实现里,就有一个 data 指针,指向了字符串的内容
2原因
之所以加这个限制,主要是为了安全:
简单说,因为 Go 里面是用指针来表示引用关系的,而 Go 的 GC 是有一套完整的逻辑的,但是 C 代码又不受 Go 控制,所以,就干脆禁止了,也是够简单粗暴的。
3原玩法
正如这个 issue 描述的,https://github.com/golang/go/issues/46787
以前这种时候,Go 官方推荐的做法是,在 C 里面申请内存,将 C 的指针返回给 Go,然后 Go 再复制成 Go 对象。
举个例子:
/*
void *get_string(int len) {
// 在 C 侧申请内存
p = malloc(len);
return p;
}
*/
function getStringFromC() {
p := C.get_string(len)
// 复制一份为 Go string
str := C.GoStringN(p, C.int(len))
// 释放 C 侧内存
C.free(p)
}
这里有两次内存复制,小内存可能还好,如果是大内存,性能影响就比较大了。
4新玩法
新的 runtime.Pinner
类型,提供了 Pin
的方法,可以将一个 Go 对象 Pin
在这个 Pinner
对象上。
然后,cgoCheckPointer
检查的时候,如果一个指针指向的对象,是一个被 Pin
住了的,就认为是合法的。
Pin 方法
具体实现上,其实也简单:
Pin 干了两件事情:
第一步,其实并没有真实用途,只是告知检查函数而已。 第二步,则是保证指针指向的 Go 对象,不会被 GC 调用,这样在 C 侧读/写这些指针值,都是安全的。
Unpin 方法
当然,这里面有一个前提是:
Pinner
对象没有被 GC 之前使用这些指针Unpin
,解除对象的 Pin
关系所以,Go runtime 加了一个检查,如果 Pinner
对象在 GC 的时候,还有 Pin
引用关系,则会抛异常,检查你是否忘记了 Unpin
.
示例
如下示例,在 Go 侧预先申请好内存,C 侧完成内存赋值。
虽然道理比较简单,但是写起来,还是比较绕的。
/*
void get_string(void *buf) {
GoSlice* slice = (GoSlice*)(buf);
// 在 C 侧完成内存赋值
memcpy(slice->data, ...);
}
*/
func getStringFromC() {
buf := make([]byte, len)
ptr := unsafe.Pointer(&buf)
sHeader := (*reflect.SliceHeader)(ptr)
// 将 slice 中的 data 指针指向的对象 Pin 住
var pinner runtime.Pinner
defer pinner.Unpin()
pinner.Pin(unsafe.Pointer(sHeader.Data))
// 可以将 slice 对象的指针,安全传给 C 了
C.get_string(ptr)
}
5Envoy Go 的玩法
在之前的 内存安全 中,我们有介绍到:
我们采用的是在 Go 侧预先申请内存,在 C++ 侧来完成赋值的方式
这里跟 Pinner 要解决的问题是一样的,我们不希望多一次的内存拷贝,但是我们的搞法更加简单粗暴:
func (c *httpCApiImpl) HttpCopyHeaders(r unsafe.Pointer, num uint64, bytes uint64) map[string][]string {
strs := make([]string, num*2)
buf := make([]byte, bytes)
// 在 Go 侧预先申请内存
sHeader := (*reflect.SliceHeader)(unsafe.Pointer(&strs))
bHeader := (*reflect.SliceHeader)(unsafe.Pointer(&buf))
res := C.envoyGoFilterHttpCopyHeaders(r, unsafe.Pointer(sHeader.Data), unsafe.Pointer(bHeader.Data))
handleCApiStatus(res)
m := make(map[string][]string, num)
// build a map from the strs slice ...
// 确保 buf 还是被 GC 引用的
runtime.KeepAlive(buf)
return m
}
6最后
严格来说,Go 调用 C 是没法从语言层面保证内存安全的,cgo 只所以默认就开启了 cgocheck
的指针检查,只是为了尽量避免一些低级的错误;以及为了让后续的 Go 有更大的改动空间,毕竟 Go 还是挺看重向后兼容的。
所以,我们搞 Envoy Go 就采取了比较简单粗暴的玩法,当然,因为我们涉及的 cgo API 并不多,经过严谨的考量实现,是可以保证安全的,同时性能也是更好的。
最后,很高兴看到 cgo 的持续优化,Pinner 的这个改动,其实也不小的,搞了一年多才合并,也挺不容易的