← Debugging Guides

阅读中文版本

Go — Slice Append Aliasing Corrupts Data

Debug silent data corruption when two slices share a backing array, predict when append copies versus writes in place, and clone slices at ownership boundaries.

intermediatedebugginggoslicesmemory

Symptoms

  • A slice that was sent or stored earlier changes values later, with no writes to it in the code path
  • Two logically separate slices show coupled mutations: writing one changes the other
  • The bug is data-size dependent: small inputs behave, inputs beyond a threshold corrupt

Possible causes

  • A subslice (parent[1:4]) shares the parent backing array; writes through it modify the parent
  • append reuses spare capacity of the shared array when it fits, overwriting elements another slice still uses
  • A slice is stored in a cache or sent to a channel, and the producer keeps appending to the same header
  • A function takes a slice, appends, and ignores that the result may or may not be a new array depending on capacity

Diagnosis steps

Inspect capacity versus length at the boundary

go run -gcflags="-m" ./alias 2>&1 | head; or add prints

Interpretation

Printing len and cap of both slices reveals the shared-capacity condition: cap-len headroom on the parent is exactly the room append will reuse.

Prove the sharing with a write probe

go test -run TestAlias -v ./sliceutil/

Interpretation

Write through one slice, read the other: changed values prove a shared backing array conclusively.

Find the send/store points

grep -Rn "ch <- \|cache.Set(\|return.*\[" src/ | head -20

Interpretation

Every place a slice escapes the current function is an ownership boundary where aliasing becomes a latent bug.

Root cause

Go slices are views over a backing array. Slicing and appending with spare capacity both operate on the same array: append copies only when capacity is exceeded. Two slice headers can therefore reference one array, and a write or in-place append through one header changes what the other header observes.

Fix

Clone at ownership boundaries with an explicit copy (append([]T(nil), s...) or slices.Clone), return append results instead of relying on mutation, use full slice expressions parent[1:4:4] to cap capacity when handing out subslices, and document which functions alias their inputs.

Risk note

Cloning every slice everywhere multiplies allocations and hides the performance contract. Clone at boundaries, alias deliberately inside a single owner.

Verify the fix

Write a test that appends past and within capacity on aliased slices, asserting isolation at boundaries and documented aliasing internally.

Symptom walkthrough

The corruption pattern has a signature: a stored or sent slice mutates without any code writing to it.

batch := make([]Event, 0, 128)
for _, e := range raw {
    batch = append(batch, e)
    if len(batch) == 128 {
        ch <- batch            // header escapes; array shared
    }
}

The consumer stores the batch. Later appends, while cap permits, write into the same backing array the consumer still reads. The size dependence is the tell: with capacity 128 and batches of exactly 128, the next append allocates a new array — safe. With capacity 256 and batches of 128, the next 128 appends reuse the array — corruption. Predict it with the arithmetic: append copies only when len+1 > cap.

Fix in practice

Clone wherever ownership transfers:

import "slices"

ch <- slices.Clone(batch)      // consumer owns its own array

Or when you hand out a window, cut capacity with a three-index slice:

view := parent[low:high:high]   // cap == len: append must copy

Inside a single owner, aliasing is fine and fast — document it:

// TrimPrefix aliases src; the result shares its backing array.
func TrimPrefix(src []byte, p string) []byte

The audit that finds latent cases: grep every channel send, cache store, and struct-field assignment of a slice, and ask whether the producer can append afterward. If yes, clone.