Go增加 runtime.Pinner类型,有哪些新玩法?

2023年 7月 11日 52.3k 0

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原因

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

  • 如果在 C 里面读取这些指针值,但是,Go 侧也有可能改写这些指针值,从而导致原来这些指针指向的对象,已经被 GC 释放了,C 侧就可能会有非法读了
  • 甚至,C 侧还可能写这些指针值,如果 GC 已经标记一个 Go 对象可以被删除了,但是 C 又将一个指针指向了这个对象,这就会在 Go 侧产生一个非法引用。
  • 简单说,因为 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 干了两件事情:

  • 将指针指向的 Span,标记为 pin,以便 cgoCheckPointer 来判断是否已经 Pin 住了
  • 将指针挂在自身的 refs 切片上,也就是构建了 Pinner 对象和这个 Go 对象的引用关系
  • 第一步,其实并没有真实用途,只是告知检查函数而已。 第二步,则是保证指针指向的 Go 对象,不会被 GC 调用,这样在 C 侧读/写这些指针值,都是安全的。

    Unpin 方法

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

  • C 只能在 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
    }
    
  • 我们直接 disable cgocheck(此处其实不依赖,我们其他地方有依赖)
  • 引用关系,我们自己维护,确保在 C 返回之后,所用到的指针,都是被 Go GC 所引用的
  • 6最后

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

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

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

    相关文章

    JavaScript2024新功能:Object.groupBy、正则表达式v标志
    PHP trim 函数对多字节字符的使用和限制
    新函数 json_validate() 、randomizer 类扩展…20 个PHP 8.3 新特性全面解析
    使用HTMX为WordPress增效:如何在不使用复杂框架的情况下增强平台功能
    为React 19做准备:WordPress 6.6用户指南
    如何删除WordPress中的所有评论

    发布评论