译文 并发安全的集中式指针管理设施

Cluas · 2021年08月25日 · 264 次阅读
本帖已被设为精华帖!

在 Go 1.17 发行版中,我们贡献了一个新的 cgo 设施runtime/cgo.Handle,以帮助未来的 cgo 应用在 Go 和 C 之间传递指针的同时,更好、更容易地构建并发安全的应用。本文将通过询问该功能为我们提供了什么,为什么我们需要这样一个设施,以及我们最终究竟如何贡献具体实现来引导我们了解这个功能。

从 Cgo 和 X Window 剪贴板开始

Cgo 是在 Go 中与 C 语言设施进行交互的事实标准。然而,我们有多少次需要在 Go 中与 C 语言进行交互呢?这个问题的答案取决于我们在系统层面上的工作程度,或者我们有多少次需要利用传统的 C 库,比如用于图像处理。每当 Go 程序需要使用一个来自 C 的遗留物时,它需要导入一种 C的专用包,如下所示,然后在 Go 方面,人们可以通过导入的C符号简单地调用myprint函数。

/*
#include <stdio.h>

void myprint() {
    printf("Hello %s", "World");
}
*/
import "C"

func main() {
    C.myprint()
    // Output:
    // Hello World
}

几个月前,当我们在建立一个提供跨平台剪贴板访问的新的包golang.design/x/clipboard时, 我们发现,Go 中缺乏这样的设施,尽管有很多野路子,但仍然存在健全性和性能问题。

golang.design/x/clipboard软件包中,我们不得不与 cgo 合作来访问系统级的 API(从技术上讲,它是一个来自传统的、广泛使用的 C 系统的 API),但缺乏在 C 代码侧了解执行进度的设施。例如,在 Go 代码这边,我们必须在一个 goroutine 中调用 C 代码,然后并行地做其他事情。

go func() {
    C.doWork() // Cgo: 调用一个C函数,并在C代码侧做一些事情
}()

// .. 在Go代码侧做点事 ..

然而,在某些情况下,我们需要一种机制来了解 C 代码的执行进度,这就带来了 Go 和 C 之间通信和同步的需要。例如,如果我们需要我们的 Go 代码等待 C 代码完成一些初始化工作,直到某个执行点执行,我们将需要这种类型的通信来精确的了解 C 函数的执行进度。

我们遇到的一个真实的例子是需要与剪贴板设施互动。在 Linux 的X Window 环境中,剪贴板是分散的,只能由每个应用程序拥有。需要访问剪贴板信息的人需要创建他们的剪贴板实例。假设一个应用程序A想把某些东西粘贴到剪贴板上,它必须向 X Window 服务器提出请求,然后成为剪贴板的所有者,在其他应用程序发出复制请求时把信息送回去。

这种设计被认为是自然的,经常需要应用程序进行合作。如果另一个应用程序B试图提出请求,成为剪贴板的下一个所有者,那么A将失去其所有权。之后,来自应用程序CD等的复制请求将被转发给应用程序B而不是A。类似于一个共享的内存区域被别人覆盖了,而原来的所有者失去了访问权。

根据以上的上下文信息,我们可以理解,在一个应用程序开始 "粘贴"(服务)剪贴板信息之前,它首先要获得剪贴板的所有权。在我们获得所有权之前,剪贴板信息将不能用于访问目的。 换句话说,如果一个剪贴板 API 被设计成如下方式:

clipboard.Write("某些信息")

我们必须从内部保证,当函数返回时,信息应该可以被其他应用程序访问。

当时,我们处理这个问题的第一个想法是,从 Go 到 C 传递一个channel,然后通过channel从 C 到 Go 发送一个值。经过快速的研究,我们意识到这是不可能的,因为由于Cgo 中传递指针的规则channel不能作为一个值在 C 和 Go 之间传递(见之前的提案文件)。即使有办法将整个channel的值传递给 C,在 C 代码侧也没有设施可以通过该channel发送值,因为 C 没有<-操作符的语言支持。

下一个想法是传递一个函数回调,然后让它在 C 代码侧被调用。该函数的执行将使用所需的channel向等待的 goroutine 发送一个通知。

经过几次尝试,我们发现唯一可能的方法是附加一个全局函数指针,并通过一个函数包装器使其被调用:

/*
int myfunc(void* go_value);
*/
import "C"

// 这个funcCallback试图避免在运行时直接出现恐慌错误。
// 传递给Cgo,因为它违反了指针传递规则:
//
//   panic: runtime error: cgo argument has Go pointer to Go pointer
var (
    funcCallback   func()
    funcCallbackMu sync.Mutex
)

type gocallback struct{ f func() }

func main() {
    go func() {
        ret := C.myfunc(unsafe.Pointer(&gocallback{func() {
            funcCallbackMu.Lock()
            f := funcCallback // 必须使用一个全局函数变量。
            funcCallbackMu.Unlock()
            f()
        }}))
        // ... 搞事 ...
    }()
    // ... 搞事 ...
}

在上面的例子中,Go 一方的gocallback指针是通过 C 函数myfunc传递的。在 C 代码侧,将有一个使用go_func_callback的调用,通过传递结构gocallback作为参数,在 C 代码侧被调用:

// myfunc将在需要时触发一个回调,c_func,并通过void*参数传递
// gocallback的数据通过void*参数。
void c_func(void *data) {
    void *gocallback = userData;
    // the gocallback is received as a pointer, we pass it as an argument
    // to the go_func_callback
    go_func_callback(gocallback);
}

go_func_callback知道它的参数被打造成gocallback。因此,一个类型转换是安全的,可以做调用:

//go:export go_func_callback
func go_func_callback(c unsafe.Pointer) {
    (*gocallback)(c).call()
}

func (c *gocallback) call() { c.f() }

在 "gocallback "中的函数 "f "正是我们想要调用的东西:

func() {
    funcCallbackMu.Lock()
    f := funcCallback // 必须使用一个全局函数变量。
    funcCallbackMu.Unlock()
    f()               // 得到调用
}

注意,funcCallback必须是一个全局函数变量。否则,就违反了前面提到的cgo 指针传递规则

此外,对于上述代码的可读性,人们的直接反应是:太复杂了。所演示的方法一次只能赋值一个函数,这也违反了并发的本质。任何按程序专用的应用程序都不会从这种方法中受益,因为它们需要按程序函数回调,而不是单一的全局回调。当时,我们不知道是否有更好的、优雅的方法来处理它。

通过研究,我们发现这个需求在社区中经常出现,而且在golang/go#37033中也被提出。幸运的是,这样的设施现在已经在 Go 1.17 中准备就绪 :)

什么是runtime/cgo.Handle

新的runtime/cgo.Handle提供了一种在 Go 和 C 之间传递包含 Go 指针(由 Go 分配的内存指针)的值的方法,而不违反 cgo 的指针传递规则。Handle是一个整数值,可以代表任何 Go 值。Handle可以通过 C 语言传递并返回到 Go,Go 代码可以使用Handle来检索原始 Go 值。最终的 API 设计建议如下:

package cgo

type Handle uintptr


// NewHandle 返回一个给定值的句柄。
//
// 该句柄在程序对其调用Delete之前一直有效。该句柄
// 使用资源,而且这个包假定C代码可能会保留这个句柄。
// 所以当句柄不再需要时,程序必须明确地调用Delete。
//
// 预期的用途是将返回的句柄传递给C代码,由C代码
// 将其传回给Go,由Go调用Value。
func NewHandle(v interface{}) Handle

// Value返回一个有效句柄的相关Go值。
//
// 如果句柄是无效的,该方法就会陷入恐慌。
func (h Handle) Value() interface{}

// Delete 会使一个句柄失效。这个方法应该只被调用一次
// 程序不再需要将句柄传递给C,并且C代码
// 不再有一个句柄值的拷贝。
//
// 如果句柄是无效的,该方法就会慌乱
func (h Handle) Delete()

我们可以看到:cgo.NewHandle为任何给定的值返回一个句柄;方法cgo.(Handle).Value返回该句柄对应的 Go 值;每当我们需要删除该值时,可以调用cgo.(Handle).Delete

最直接的例子是使用Handle在 Go 和 C 之间传递一个字符串。在 Go 的一方:

package main
/*
#include <stdint.h> // for uintptr_t
extern void MyGoPrint(uintptr_t handle);
void myprint(uintptr_t handle);
*/
import "C"
import "runtime/cgo"

func main() {
    s := "Hello The golang.design Initiative"
    C.myprint(C.uintptr_t(cgo.NewHandle(s)))
    // Output:
    // Hello The golang.design Initiative
}

字符串s通过一个创建的句柄传递给 C 函数myprint,在 C 代码侧:

#include <stdint.h> // for uintptr_t

// A Go function
extern void MyGoPrint(uintptr_t handle);
// A C function
void myprint(uintptr_t handle) {
    MyGoPrint(handle);
}

myprint将句柄传回给 Go 函数MyGoPrint:

//go:export MyGoPrint
func MyGoPrint(handle C.uintptr_t) {
    h := cgo.Handle(handle)
    s := h.Value().(string)
    println(s)
    h.Delete()
}

MyGoPrint使用cgo.(Handle).Value()查询该值并打印出来。然后使用cgo.(Handle).Delete()删除该值。

有了这个新设施,我们可以更好地简化之前提到的函数回调模式:

/*
#include <stdint.h>

int myfunc(void* go_value);
*/
import "C"

func main() {

    ch := make(chan struct{})
    handle := cgo.NewHandle(ch)
    go func() {
        C.myfunc(C.uintptr_t(handle)) // myfunc将在需要时调用goCallback。
        ...
    }()

    <-ch // 我们从myfunc得到了通知。
    handle.Delete() // 因此需要删除手柄。
    ...
}

//go:export goCallback
func goCallback(h C.uintptr_t) {
    v := cgo.Handle(h).Value().(chan struct{})
    v <- struct{}
}

更重要的是,cgo.Handle是一个并发安全的机制,这意味着一旦我们有了句柄号码,我们就可以在任何地方获取这个值(如果仍然可用的话),而不会受到数据竞赛的影响。

下一个问题: 如何实现cgo.Handle

第一次尝试

第一次尝试是很复杂的。由于我们需要一个集中的方式以并发安全的方式管理所有的指针,我们脑海中最快的想法是sync.Map,它将一个唯一的数字映射到所需的值。因此,我们可以很容易地使用一个全局的sync.Map:

package cgo

var m = &sync.Map{}

然而,我们必须考虑到核心的挑战: 如何分配一个运行时级别的唯一 ID?在 Go 和 C 之间传递一个整数是相对容易的,那么对于一个给定的值,什么才是唯一的表示呢?

第一个想法是内存地址。因为每个指针或值都存储在内存的某个地方,如果我们能得到这些信息,就可以很容易地将其作为值的 ID,因为每个值都有唯一的内存地址,所以很容易被用作值的 ID。

为了完成这个想法,我们需要谨慎一点:一个活生生的值的内存地址会不会在某个时候被改变?这个问题又引出了两个问题:

  1. 如果一个值在 goroutine 堆栈上怎么办?如果是这样,当 goroutine 死亡时,该值将被释放。
  2. 2.Go 是一种垃圾回收的语言。如果垃圾收集器将该值移动并压缩到一个不同的地方怎么办?那么该值的内存地址也会被改变。

根据我们多年来对运行时的经验和理解,我们了解到,1.17 之前的 Go 的垃圾收集器总是不动,机制也很难改变。这意味着,如果一个值分配在堆里,它不会被移到其他地方。有了这个事实,我们就可以解决第二个问题了。

对于第一个问题来说,这有点棘手:堆栈上的一个值可能会随着堆栈的增长而移动。更难解决的是,编译器优化可能会在堆栈之间移动数值,而运行时可能会在堆栈用完后移动堆栈。

自然得,我们可能会问:是否有可能确保一个值总是被分配在堆上而不是栈上?答案是:可以!如果我们把它变成一个interface{}。在 1.17 之前,Go 编译器的逃逸分析总是标记应该逃逸到堆的值,如果它被转换为interface{}

有了上面的所有分析,我们可以写出利用逃逸值的内存地址的以下部分实现:

// wrap wraps a Go value.
type wrap struct{ v interface{} }

func NewHandle(v interface{}) Handle {
    var k uintptr

    rv := reflect.ValueOf(v)
    switch rv.Kind() {
    case reflect.Ptr, reflect.UnsafePointer, reflect.Slice,
        reflect.Map, reflect.Chan, reflect.Func:
        if rv.IsNil() {
            panic("cgo: cannot use Handle for nil value")
        }

        k = rv.Pointer()
    default:
        // 包裹并将一个值参数变成一个指针。这使得
        // 我们总是将传递的对象存储为指针,并有助于
        // 识别哪些对象最初是指针或值
        // 当Value被调用时。
        v = &wrap{v}
        k = reflect.ValueOf(v).Pointer()
    }

    ...
}

请注意,上面的实现对这些值的处理是不同的。对于reflect.Ptr, reflect.UnsafePointer, reflect.Slice, reflect.Map, reflect.Chan, reflect.Func类型,它们已经是逃逸到堆的指针,我们可以安全地从它们那里得到地址。对于其他类型,我们需要把它们从一个值变成一个指针,并确保它们总是逃逸到堆上。这就是以下部分:

// 包裹并将一个值参数变成一个指针。这使得
// 我们总是将传递的对象存储为指针,并有助于
// 识别哪些对象最初是指针或值
// 当Value被调用时。
v = &wrap{v}
k = reflect.ValueOf(v).Pointer()

现在我们已经把一切都变成了堆上的一个逃逸值。接下来我们要问的是:如果这两个值是一样的呢?这意味着传递给cgo.NewHandle(v)'的v'是同一个对象。那么此时我们将在k中得到相同的内存地址。

当然,简单的情况是,如果地址不在全局 map 上,那么我们就不必考虑,而是将地址作为值的句柄返回:

func NewHandle(v interface{}) Handle {
    ...

    // 由于反射的原因,v被逃逸到了堆里。
    // 由于Go没有一个移动的GC(而且可能在未来很长一段时间内都是如此),
    // 在这个时候使用它的指针地址作为全局地图的键是安全的。
    // 如果在运行时内部引入移动GC,则必须重新考虑实现。
    actual, loaded := m.LoadOrStore(k, v)
    if !loaded {
        return Handle(k)
    }

    ...
}

否则,我们必须检查全局 map 中的旧值,如果它是相同的值,那么我们就返回预期的相同地址:

func NewHandle(v interface{}) Handle {
    ...

    arv := reflect.ValueOf(actual)
    switch arv.Kind() {
    case reflect.Ptr, reflect.UnsafePointer, reflect.Slice,
        reflect.Map, reflect.Chan, reflect.Func:
        // 给定的Go值的底层对象已经有其现有的句柄。
        if arv.Pointer() == k {
            return Handle(k)
        }

        // 如果加载的指针与新的指针不一致,说明由于GC的原因,
        // 该地址被用于不同的对象,其地址被重新用于新的围棋对象,
        // 也就是说,当不需要旧的Go值时,Handle没有明确调用Delete。
        // 认为这是对句柄的误用,做恐慌。
        panic("cgo: misuse of a Handle")
    default:
        panic("cgo: Handle implementation has an internal bug")
    }
}

如果现有的值与新请求的值有相同的地址, 这一定是对处理程序的误用。

因为我们用wrap结构把所有东西都变成了reflect.Ptr类型,所以不可能有其他种类的值从全局 map 中获取。如果发生这种情况,这是句柄实现中的一个内部错误。

在实现Value()方法时,我们看到为什么一个wrap结构有利:

func (h Handle) Value() interface{} {
    v, ok := m.Load(uintptr(h))
    if !ok {
        panic("cgo: misuse of an invalid Handle")
    }
    if wv, ok := v.(*wrap); ok {
        return wv.v
    }
    return v
}

因为我们可以检查当存储的对象是一个*wrap指针,这意味着它是一个指针以外的值。我们返回该值而不是存储的对象。

最后,Delete方法变得微不足道:

func (h Handle) Delete() {
    _, ok := m.LoadAndDelete(uintptr(h))
    if !ok {
        panic("cgo: misuse of an invalid Handle")
    }
}

golang.design/x/clipboard/internal/cgo中的完整实现。

被接受的方法

正如人们可能已经意识到的那样,前面的方法比预期的要复杂得多,而且非同小可:它依赖的基础是,运行时垃圾收集器不是一个移动的垃圾收集器,虽然接口会逃到堆里。

尽管内部运行时实现中的其他几个地方依赖于这些事实,例如channel实现,但它仍然比我们预期的要复杂一些。

值得注意的是,以前的NewHandle实际上表现为当提供的 Go 值指的是同一个对象时,会返回一个唯一的手柄。这就是带来实现复杂性的核心。然而,我们还有另一种可能:NewHandle总是返回一个不同的句柄,而一个 Go 值可以有多个句柄。

我们真的需要句柄是唯一的并保持它满足幂等性吗?经过与 Go 团队的简短讨论,我们达成共识,对于句柄的目的,似乎没有必要保持其唯一性,原因如下:

  1. NewHandle的语义是返回一个新的句柄,而不是一个唯一的句柄。
  2. 句柄不过是一个整数,保证它的唯一性可以防止句柄的误用,但它不能总是避免滥用,直到为时已晚。
  3. 实现的复杂性。

因此,我们需要重新思考原来的问题。如何分配一个运行时间级别的唯一 ID?

在现实中,这种方法更容易管理:我们只需要增加一个数字,而且永远不会停止。这是最常用的唯一 ID 生成方法。例如,在数据库应用中,表行的唯一 ID 总是递增的;在 Unix 时间戳中,时间总是递增的,等等。

如果我们使用同样的方法,可能的并发安全实现会是什么?使用sync.Mapatomic,我们可以产生这样的代码:

func NewHandle(v interface{}) Handle {
    h := atomic.AddUintptr(&handleIdx, 1)
    if h == 0 {
        panic("runtime/cgo: ran out of handle space")
    }

    handles.Store(h, v)
    return Handle(h)
}

var (
    handles   = sync.Map{} // map[Handle]interface{}
    handleIdx uintptr      // atomic
)

每当我们想分配一个新的 ID(NewHandle)时,可以原子式地增加句柄编号handleIdx,那么下一次分配将始终保证有一个更大的编号可以使用。有了这个分配的数字,我们可以很容易地把它存储到一个全局 map 上,这个 map 可以持久地保存所有的 Go 值。

剩下的工作就变得微不足道了。当我们想使用句柄来检索相应的 Go 值时,我们通过句柄号访问值 map:

func (h Handle) Value() interface{} {
    v, ok := handles.Load(uintptr(h))
    if !ok {
        panic("runtime/cgo: misuse of an invalid Handle")
    }
    return v
}

此外,如果我们完成了对句柄的处理,可以从值 map 中删除它:

func (h Handle) Delete() {
    _, ok := handles.LoadAndDelete(uintptr(h))
    if !ok {
        panic("runtime/cgo: misuse of an invalid Handle")
    }
}

在这个实现中,我们不需要假设运行时机制,只需要使用语言。只要 Go 1 的兼容性能保持sync.Map的承诺,就不需要重做整个Handle的设计。由于其简单性,这是 Go 团队接受的方法(见CL 295369)。

除了未来对sync.Map的重新实现优化了并行性之外,Handle将自动从中受益。让我们做一个最后的基准测试,比较一下以前的方法和现在的方法。

func BenchmarkHandle(b *testing.B) {
    b.Run("non-concurrent", func(b *testing.B) {
        for i := 0; i < b.N; i++ {
            h := cgo.NewHandle(i)
            _ = h.Value()
            h.Delete()
        }
    })
    b.Run("concurrent", func(b *testing.B) {
        b.RunParallel(func(pb *testing.PB) {
            var v int
            for pb.Next() {
                h := cgo.NewHandle(v)
                _ = h.Value()
                h.Delete()
            }
        })
    })
}
name                     old time/op  new time/op  delta
Handle/non-concurrent-8  407ns ±1%    393ns ±2%   -3.51%  (p=0.000 n=8+9)
Handle/concurrent-8      768ns ±0%    759ns ±1%   -1.21%  (p=0.003 n=9+9)

更简单,更快速,为什么不呢?

总结

这篇文章讨论了我们在 Go 1.17 版本中新引入的runtime/cgo.Handle设施。Handle工具使我们能够在 Go 和 C 之间来回传递 Go 值,而不违反 cgo 指针传递规则。在简单介绍了该功能的用法之后,我们首先讨论了基于运行时垃圾收集器不是移动的 GC 以及interface{}参数的逃逸行为的首次尝试实现。 在对 Handle 语义的模糊性和之前实现中的缺点进行了一些讨论后,我们还介绍了一种直接的、性能更好的方法,并展示了其性能。

作为一个现实世界的示范,我们已经在我们发布的两个包golang.design/x/clipboardgolang.design/x/hotkey中使用上述两种方法很长时间了。 之前在其internal/cgo包中,我们期待着在 Go 1.17 版本中切换到官方发布的runtime/cgo包。

对于未来的工作,可以预见,在已接受的实现中可能存在的限制是,在 32 位或更低的操作系统中,句柄数可能会很快耗尽句柄空间(类似于 [2038 年 当我们每秒分配 100 个句柄时,句柄空间可以在以下时间用完 0xFFFFFFF / (24 * 60 * 60 * 100) = 31 天。

*如果你有兴趣并认为这是一个严重的问题,请在发送 CL 时抄送我们,我们也有兴趣阅读你的优秀做法。

进一步阅读建议

文中引用

更多原创文章干货分享,请关注公众号
  • 加微信实战群请加微信(注明:实战群):gocnio
astaxie 将本帖设为了精华贴 08月25日 10:39
yulibaozi GoCN 每日新闻(2021-08-25) 中提及了此贴 08月25日 14:40
需要 登录 后方可回复, 如果你还没有账号请点击这里 注册