症状
污染模式有明确签名:一个被存储或发送的 slice 在没有任何代码写它的情况下变了值。典型代码是复用缓冲的批处理——batch 容量 256、每 128 条发送一次:消费者存下的批次与生产者继续 append 的是同一个底层数组,后续 128 次 append 原地覆写消费者还在读的元素。数据规模的依赖性是铁证:容量恰好用尽时 append 必然分配新数组(安全),容量有余时原地复用(污染)。预测只需一条算术:append 仅在 len+1 > cap 时复制。
诊断思路
先在边界打印两个 slice 的 len 与 cap,父 slice 的 cap-len 余量正是 append 会复用的空间;再写探针测试——通过一个 slice 写、从另一个读,值变化即 conclusively 证明共享底层数组;最后 grep 所有 channel 发送、缓存存储、slice 作为返回值或结构体字段赋值的位置,每个都是所有权边界,别名在那里变成潜伏 bug。
根因
Go slice 是底层数组上的视图。切片操作与容量未满时的 append 都作用于同一数组:append 只在超出容量时复制。两个 slice 头可以引用同一个数组,通过其中一个写入或原地 append,另一个观察到的内容就变了。
修复与验证
所有权转移处克隆:ch <- slices.Clone(batch)(Go 1.21+)或 append([]T(nil), s…);对外分发窗口时用三下标切片 parent[low:high:high] 把容量钉死为长度,逼 append 复制;单一所有者内部别名既正确又快,用注释文档化(“TrimPrefix 与 src 共享底层数组”)。验证时写一个分别测试“容量内 append”与“超容量 append”的用例:边界处断言隔离(克隆生效)、内部断言文档化的别名行为不变。到处克隆会放大分配并掩盖性能契约——只在边界克隆、内部有意别名。
代码示例
batch := make([]Event, 0, 128)
for _, e := range raw {
batch = append(batch, e)
if len(batch) == 128 {
ch <- batch // header escapes; array shared
}
}
import "slices"
ch <- slices.Clone(batch) // consumer owns its own array
view := parent[low:high:high] // cap == len: append must copy
// TrimPrefix aliases src; the result shares its backing array.
func TrimPrefix(src []byte, p string) []byte