0%

cgo 改进:Go 1.21 新增 runtime.Pinner 类型

这两天偶然间发现,Go 1.21 版本,将增加 runtime.Pinner类型。简单看了下,想要解决的问题,跟我们搞 Envoy Go 扩展的时候,非常接近。

cgoCheckPointer

得先从 cgo 的一个限制说起:

如果将一个 Go 对象的指针传给 C,那么这个 Go 对象里,是不能再有指针指向 Go 对象的(空指针可以)。

这个检查是在运行时进行的,cgoCheckPointer,如果检查到了,就抛 panic: cgo argument has Go pointer to Go pointer

举个简单的例子,如果我们将一个 Go string 的指针,传给 C,就会触发这个 panic。因为 string 的实现里,就有一个 data 指针,指向了字符串的内容

原因

之所以加这个限制,主要是为了安全:

  1. 如果在 C 里面读取这些指针值,但是,Go 侧也有可能改写这些指针值,从而导致原来这些指针指向的对象,已经被 GC 释放了,C 侧就可能会有非法读了
  2. 甚至,C 侧还可能写这些指针值,如果 GC 已经标记一个 Go 对象可以被删除了,但是 C 又将一个指针指向了这个对象,这就会在 Go 侧产生一个非法引用。

简单说,因为 Go 里面是用指针来表示引用关系的,而 Go 的 GC 是有一套完整的逻辑的,但是 C 代码又不受 Go 控制,所以,就干脆禁止了,也是够简单粗暴的。

原玩法

正如这个 issue 描述的,https://github.com/golang/go/issues/46787

以前这种时候,Go 官方推荐的做法是,在 C 里面申请内存,将 C 的指针返回给 Go,然后 Go 再复制成 Go 对象。

举个例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
/*
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)
}

这里有两次内存复制,小内存可能还好,如果是大内存,性能影响就比较大了。

新玩法

新的 runtime.Pinner 类型,提供了 Pin 的方法,可以将一个 Go 对象 Pin 在这个 Pinner 对象上。

然后,cgoCheckPointer 检查的时候,如果一个指针指向的对象,是一个被 Pin 住了的,就认为是合法的。

Pin 方法

具体实现上,其实也简单:

Pin 干了两件事情:

  1. 将指针指向的 Span,标记为 pin,以便 cgoCheckPointer 来判断是否已经 Pin 住了
  2. 将指针挂在自身的 refs 切片上,也就是构建了 Pinner 对象和这个 Go 对象的引用关系

第一步,其实并没有真实用途,只是告知检查函数而已。
第二步,则是保证指针指向的 Go 对象,不会被 GC 调用,这样在 C 侧读/写这些指针值,都是安全的。

Unpin 方法

当然,这里面有一个前提是:

  1. C 只能在 Pinner 对象没有被 GC 之前使用这些指针
  2. 以及,用完之后,需要显式的 Unpin,解除对象的 Pin 关系

所以,Go runtime 加了一个检查,如果 Pinner 对象在 GC 的时候,还有 Pin 引用关系,则会抛异常,检查你是否忘记了 Unpin.

示例

如下示例,在 Go 侧预先申请好内存,C 侧完成内存赋值。

虽然道理比较简单,但是写起来,还是比较绕的。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
/*
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)
}

Envoy Go 的玩法

在之前的 内存安全 中,我们有介绍到:

我们采用的是在 Go 侧预先申请内存,在 C++ 侧来完成赋值的方式

这里跟 Pinner 要解决的问题是一样的,我们不希望多一次的内存拷贝,但是我们的搞法更加简单粗暴:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
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
}
  1. 我们直接 disable cgocheck(此处其实不依赖,我们其他地方有依赖)
  2. 引用关系,我们自己维护,确保在 C 返回之后,所用到的指针,都是被 Go GC 所引用的

最后

严格来说,Go 调用 C 是没法从语言层面保证内存安全的,cgo 只所以默认就开启了 cgocheck 的指针检查,只是为了尽量避免一些低级的错误;以及为了让后续的 Go 有更大的改动空间,毕竟 Go 还是挺看重向后兼容的。

所以,我们搞 Envoy Go 就采取了比较简单粗暴的玩法,当然,因为我们涉及的 cgo API 并不多,经过严谨的考量实现,是可以保证安全的,同时性能也是更好的。

最后,很高兴看到 cgo 的持续优化,Pinner 的这个改动,其实也不小的,搞了一年多才合并,也挺不容易的。