编程 子切片 append 把兄弟数据改掉了:Go 共享底层数组污染排查

2026-09-07 00:14:59

子切片 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

自查

遇到“切片处理结果互相污染”时,按这个顺序看:

  1. 切片是从哪里切出来的?底层数组是不是共享的?
  2. append 前,这个子切片的 cap 还剩多少?
  3. append 的返回值是写回了共享区,还是已经扩容到新数组?
  4. 需要独立数据就显式 copy;需要防止写回就三索引收紧容量。

只要还共享一块底层数组,append 就不是一个“纯函数”。

复制全文 生成海报 Go开发 Go slice 并发

推荐文章

程序员茄子在线接单