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.go 的 func (s *mspan) reportZombies() 方法,而调用这个方法的地方只有 GC sweep 相关代码。通过这个日志信息可以推测,大概率是因为各种 unsafe 操作造成 GC mark/sweep 环节出现了错误标记,导致内存释放异常。
寻找可复现问题的测试代码
考虑到 go-clone 库的精髓就是通过各种 unsafe 操作绕过很多语言层面的限制从而达到高性能深拷贝的目的,触发 GC 问题的可能性确实很大,当务之急是能写一个可稳定复现崩溃的程序,有了这个程序之后才能通过不断修改代码看是否真的修复了问题。由于 GC 隐藏在普通 Go 代码逻辑的幕后,我们只能通过 runtime.GC() 强制触发 GC,但并没有方法能够指示 GC 做指定动作,并发三色标记的过程是很复杂的,而这里面任何一个环节都潜在会与 go-clone 库的逻辑发生并发冲突。
解决难题的第一步是“望闻问切”,尽可能缩小问题范围。通过询问,我发现这个问题起码跟 Wrap、Slowly 这些方法没关系,从而不用考虑这些代码相关的逻辑。 注意到出问题的应用场景是持续高并发的 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 里大部分浅拷贝其实是 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 发布在知乎。