子切片 append 把兄弟数据改掉了:Go 共享底层数组污染排查
一段典型的现场:同一份数据切出两个子切片,A 分支只处理前半段,B 分支只处理后半段。A 分支 append 补了数据后,B 分支读到的值变了,而 B 分支的代码一行都没执行。
data := []int{1, 2, 3, 4, 5}
a := data[:2]
b := data[2:]
a = append(a, 100) // A 想补充一个字段
fmt.Println(a) // [1 2 100]
fmt.Println(b) // [100 4 5],被 A 改了
fmt.Println(data) // [1 2 100 4 5]
a 的 append 把 100 写进了共享底层数组的 index 2,而这个位置原本属于 b 的开头。
根因:切片只是带 len/cap 的窗口
切片在运行时是一个三字段 header,不是“自带数组的容器”:
type sliceHeader struct {
Data uintptr
Len int
Cap int
}
多个切片只要从同一个底层数组切出来,就会共享同一块内存。
底层数组: [1 2 3 4 5]
data: ptr=&arr[0] len=5 cap=5
a: ptr=&arr[0] len=2 cap=5
b: ptr=&arr[2] len=3 cap=3
a := data[:2] 不复制元素,只是把窗口缩小,cap 仍然继承到 data 底层数组末尾。
append 不保证分配新数组
append 是否污染旧数据,取决于当时的容量关系:
len < cap:直接往共享底层数组的下一个位置写入,然后返回一个 len+1 的新 header。len == cap:真正分配一块新数组,把旧元素拷贝过去,再继续 append。
看最小复现:
src := []int{0, 1, 2, 3, 4}
head := src[:2] // len=2, cap=5
grown := append(head, 99)
fmt.Println(grown) // [0 1 99]
fmt.Println(src) // [0 1 99 3 4]
head 还有 3 个空余容量,所以 append 在原地写入了 src[2]。认为“append 一定会返回独立数据”是错误假设。
修复 1:要独立数据,显式 copy
如果 A 分支处理的数据后续允许独立变化,就不能直接拿 data[:2] 去 append,要先复制:
src := []int{0, 1, 2, 3, 4}
head := make([]int, 2)
copy(head, src[:2])
head = append(head, 99)
fmt.Println(src) // [0 1 2 3 4]
fmt.Println(head) // [0 1 99]
另一种更短的写法:
head := append([]int(nil), src[:2]...)
head = append(head, 99)
两种方式都是先分配一块独立数组,再赋值给需要 append 的变量。
修复 2:只读共享,用三索引切片收紧 cap
如果后续只允许读取原数据,不允许 append 写回原共享区,可以用三索引切片限制容量:
limited := src[:2:2] // len=2, cap=2
limited = append(limited, 99)
fmt.Println(src) // [0 1 2 3 4],没被污染
src[low:high:max] 的规则是:
len = high - low
cap = max - low
low <= high <= max <= cap(src)
src[:2:2] 把 cap 压到和 len 一样,后续 append 立刻触发扩容,回到一块新数组,不再写共享底层数组。
如果只写 src[:2],cap 会一直继承到原数组末尾,后续 append 就可能越过你以为的分片边界,覆盖到别的切片空间。
并发:共享底层数组别顺手 append
同一 goroutine 里,这个问题的表现是“数据被改”。跨 goroutine 后,多个 goroutine 对同一个底层数组 append,可能叠加出数据竞争。
切片 header 本身也带 len/cap,会随 append 被读取和更新,不是并发安全的数据结构。看起来每个 goroutine 拿到的子区间不重叠,但 append 的容量可能继承到共享数组深处,导致一个 goroutine 写进另一个 goroutine 的窗口;如果同时更新同一个切片变量,则会直接竞争 header。
排查建议:
- 需要并发写时,先把各自区间
copy成独立数组。 - 只读共享可以用三索引切片收紧 cap。
- 凡是共享 slice 的并发路径,至少要跑一遍
go test -race。
自查
遇到“切片处理结果互相污染”时,按这个顺序看:
- 切片是从哪里切出来的?底层数组是不是共享的?
- append 前,这个子切片的 cap 还剩多少?
- append 的返回值是写回了共享区,还是已经扩容到新数组?
- 需要独立数据就显式
copy;需要防止写回就三索引收紧容量。
只要还共享一块底层数组,append 就不是一个“纯函数”。