跳到主要内容
HHuan Du
9 分钟阅读

go-clone:当深度拷贝遇到 GC,如何避免出错

一个每天偶发几次的崩溃:unsafe 拷贝绕过了写屏障,破坏了 GC 的三色标记,最后如何安全地修好它。

  • #go
  • #go-clone
  • #gc
  • #unsafe

闲来写写码,没空也得抽空修 bug。

问题背景

近期,go-clone 库的一位用户提了一个诡异的偶发崩溃 issue,使用 Go 版本是 go1.15,生产环境中偶现但每天都能遇到几次,但本地测试却怎么都复现不了这个问题。

关键的崩溃日志如下所示:

fatal error: found pointer to free object

runtime: marked free object in span 0x7f61bf7566a0, elemsize=16 freeindex=0 (bad use of unsafe.Pointer? try -d=checkptr)

很显然这个问题跟 GC 相关,查找 Go 源码后能看到这段信息出自 runtime/mgcsweep.gofunc (s *mspan) reportZombies() 方法,而调用这个方法的地方只有 GC sweep 相关代码。通过这个日志信息可以推测,大概率是因为各种 unsafe 操作造成 GC mark/sweep 环节出现了错误标记,导致内存释放异常。

寻找可复现问题的测试代码

考虑到 go-clone 库的精髓就是通过各种 unsafe 操作绕过很多语言层面的限制从而达到高性能深拷贝的目的,触发 GC 问题的可能性确实很大,当务之急是能写一个可稳定复现崩溃的程序,有了这个程序之后才能通过不断修改代码看是否真的修复了问题。由于 GC 隐藏在普通 Go 代码逻辑的幕后,我们只能通过 runtime.GC() 强制触发 GC,但并没有方法能够指示 GC 做指定动作,并发三色标记的过程是很复杂的,而这里面任何一个环节都潜在会与 go-clone 库的逻辑发生并发冲突。

解决难题的第一步是“望闻问切”,尽可能缩小问题范围。通过询问,我发现这个问题起码跟 WrapSlowly 这些方法没关系,从而不用考虑这些代码相关的逻辑。 注意到出问题的应用场景是持续高并发的 Clone 一个带有 []*T 结构的复杂结构,优先怀疑跟 slice、结构指针相关的逻辑有问题。

这种猜测还无法让我直接能够深入代码找问题,还需要构造一个可以复现的 case。考虑到三色标记过程中需要用写屏障来确保标记信息足够准确,优先怀疑跟某些代码突破了写屏障导致问题,此时可以用一个容量有点大的 slice 来存储复杂结构,反复制造垃圾内存的方法来让制造潜在的冲突,看看是否能重现问题。

经过一些尝试,我发现了一种稳定重现问题的方法,这是我最开始构造的 case,里面的注释是我的猜测的过程:

type T1 struct {
    F1 string // 这里在怀疑会不会是 string 的问题。
}
type T2 struct {
    F1 *T1 // 这里在怀疑是不是结构指针的问题。
}
type T3 struct {
    F1 []*T2    // 这里在怀疑是不是 slice of struct 的问题。
    F2 []string // 这里怀疑是不是 slice of string 的问题。
}
makeT1 := func(n int) *T1 {
    return &T1{
        F1: strconv.Itoa(n), // 确保字符串都是新申请的内存。
    }
}
makeT2Slice := func(n int) []*T2 {
    l := n%5 + 1 // 申请一个有长度但不太长的 slice。
    slice := make([]*T2, 0, l)

    for i := 0; i < l; i++ {
        slice = append(slice, makeT1(n))
    }

    return slice
}
makeT3 := func(n int) *T3 {
    return &T3{
        F1: makeT2Slice(n),
        F2: []string{"foo", strconv.Itoa(n)},
    }
}

max := runtime.GOMAXPROCS(0) // 尽量占用所有 CPU 资源从而造成竞争环境。
var wg sync.WaitGroup
wg.Add(max)

for i := 0; i < max; i++ {
    go func() {
        defer wg.Done()

        size := 1024
        buffer := make([]*T3, size)

        for n := 0; n < math.MaxInt32; n++ {
            // 将新申请的内存存入一个循环数组,这样会让这段内存存活一段时间,
            // 但又不是特别久,GC 会频繁的回收这些 clone 出来的内存。
            buffer[n%size] = clone.Clone(makeT3(n)).(*T3)
        }
    }()
}

done := make(chan bool, 1)
ticker := time.NewTicker(time.Second)

go func() {
    wg.Wait()
    done <- true
}()

for {
    select {
    case <-ticker.C:
        // 构造一个 1s 执行一次的定时器,强制触发 GC。
        runtime.GC()

    case <-done:
        return
    }
}

经过上面一大段代码,我终于稳定复现了问题,一般持续运行 30s 之内就能触发跟 issue 里面一模一样的崩溃,于是我有了一个可用于调试的基础。这段测试代码依然过于复杂,不利于将问题最终定位到具体的代码行里面,我对测试代码也做了一些精简,这个过程比较枯燥,就是不断的减少类型和字段并跑一下看看是否还能复现问题,最终发现只需要以下的代码就能稳定复现问题,并且测试代码应该已经精简到极致。

type T struct {
    F1 string
}
makeTSlice := func(n int) []*T {
    l := n%5 + 1
    slice := make([]*T, 0, l)

    for i := 0; i < l; i++ {
        slice = append(slice, &T{
            F1: strconv.Itoa(n),
        })
    }

    return slice
}

// 略……

for i := 0; i < max; i++ {
    go func() {
        defer wg.Done()

        size := 1024
        buffer := make([][]*T, size) // 必须是 slice of slice of *T 才能复现问题。

        for n := 0; n < math.MaxInt32; n++ {
            buffer[n%size] = clone.Clone(makeTSlice(n)).([]*T)
        }
    }()
}

// 略……

修复问题

经过充分精简的测试代码已经基本能说明问题,总结一下就两个特征:

  • 确实跟包含指针的 slice 相关,[][]*T 这种形态是最小能复现的类型,其实写成 []*T 并且 T 里面包含 slice field 也可以复现问题。
  • 跟 string 相关,结构里包含 string 字段会比较容易出现问题。

考虑到 go-clone 库的原理,我怀疑这个问题大概率是因为浅拷贝(shadow copy)破坏了写屏障。关于 go-clone 库的内部原理详见下文。

go-clone:极致优化深拷贝效率与拷贝私有字段

在 Go 里大部分浅拷贝其实是 GC 安全的。比如我们如果将一个结构(而非指针)以 interface{} 形式传给一个函数,这个结构的内存会默默的进行一次浅拷贝,就算结构里面包含复杂结构的指针,包括 slice、map、channel 等,都不会对 GC 造成任何的困扰。这是因为这个拷贝操作会触发写屏障,就算这个内存区域的颜色是白色(即将被回收),也会因为写屏障改成灰色,从而安全的进行下一轮标记计算。写屏障的实现代码是由编译器生成代码的时候动态生成的,可以通过编译测试程序并用 go tool objdump 命令反汇编来查看实现细节,函数名为 runtime.gcWriteBarrier。由于细节跟这个问题不相关,这里按下不表。

但是浅拷贝的 GC 安全性的前提是不使用 unsafe 库正常的写 Go 代码,Go 在编译期间会识别这种拷贝并插入写屏障的代码,但如果一旦用了 unsafe 就会让这个过程不安全。最不安全的是下面这种操作:

var from, to T
const sz = unsafe.Sizeof(from)
p1 := unsafe.Pointer(&from)
p2 := unsafe.Pointer(&to)
copy((*[sz]byte)(p2)[:sz:sz], (*[sz]byte)(p1)[:sz:sz])

这种操作会完全骗过编译器,让编译器以为拷贝的是一个无需插入写屏障的 []byte 数据,这就让 from 的内存失去了由白变灰的机会。假如在拷贝前 from 在已经是白色且 to 已经是黑色,from 里面的指针并没有机会由白变灰,从而导致出现内存错误。

幸好在 go-clone 库里真正命中这种情况的代码并不多,只有在 shadowCopy() 对 struct 操作的过程中为了更极致的优化性能采取了这种策略,并不影响大局,删除以下代码就解决了问题,并且也不会有明显的性能损失。

if src.CanAddr() {
    srcPtr := unsafe.Pointer(src.UnsafeAddr())
    sz := t.Size()
    copy((*[maxByteSize]byte)(p)[:sz:sz], (*[maxByteSize]byte)(srcPtr)[:sz:sz])
    return
}

细节讨论

如何安全的拷贝 interface

接口 interface{} 的底层实现也是 struct,里面包含了一个指向真实内存的指针和一个指向类型信息的指针,拷贝接口的时候如果采用 [2]uintptr[]byte 的方式拷贝理论上也会遇到同样的问题,但我始终无法找到一种能产生崩溃的测试代码,为了安全起见我还是优化了 shadowCopy() 里面跟 interface 拷贝相关的逻辑。

修改的方法很有趣,本质上是骗过 Go 编译器,让它帮我生成必要的写屏障相关代码。

我直接按照字节数来定义一个自己的数据结构:

const sizeOfPointers = unsafe.Sizeof((interface{})(0)) / unsafe.Sizeof(uintptr(0))

// interfaceData 用来表达 interface{} 的底层结构。
// 我们其实不关心 Go runtime 究竟如何表达 interface{},
// 只需要确保这个结构里面包含指针即可。
type interfaceData struct {
	_ [sizeOfPointers]unsafe.Pointer
}

这个结构只需要包含指针即可,这样 Go 编译器就会帮我们在浅拷贝的地方生成写屏障相关代码,从而让这个拷贝做到 GC 安全。更妙的是,就算我们定义的这个结构跟实际并不相符也没关系,GC 甚至不会知道这个结构的存在,因为 Go 是在申请内存的时候标记内存的类型,而我们所有新申请内存的时候都用的是正确的类型,这个类型并不影响 GC 结果。

为什么 reflect 库是安全的

问题到此其实已经基本解决,但还有一个疑惑悬而未决。将内存用 unsafe 转成 []byte 直接进行拷贝确实不安全,那 Go runtime 里面的类似代码是如何保证 GC 安全的呢?比如 reflect 库里面各种 reflect.Value 操作也大量存在类似逻辑,为什么它是非常安全的呢?

这个原因就藏在 Go 源码里。Go runtime 有一个私有函数 typedmemmove() 实现了带有类型信息和写屏障的 GC 安全内存拷贝。这个当然只有 runtime 能用, 我并不打算在让 go-clone 依赖这个函数,实际上也没必要,reflect.Value 已经提供了非常类似的 Set() 方法来间接的调用这个函数。

Go GC 的三色标记具体是怎么工作的

虽然我之前也研究过三色标记,但是总是容易忘记很多细节。这里我要推荐 @Draveness《Go 语言设计与实现》中关于三色标记的部分,非常的清晰和详实,值得反复阅读。珠玉在前,这里就不再复述相关原理,大家自己去看看吧。

本文最初于 2022-03-26 发布在知乎