A stage reads 4 MiB chunks from an io.Reader and keeps 32-byte subslices of each; live heap climbs to gigabytes. Why?
answer
- slicing shares, it never copies
- the collector frees whole allocations
- live heap tracks record count, not record size
- capacity in the millions on a tiny record
- clone at the point lifetimes diverge
basics
~20 sEach 32-byte subslice still points into its 4 MiB backing array, and the collector keeps a whole array alive while anything references any part of it. Copy what you retain with bytes.Clone, and the chunk becomes collectable.
solid answer
~40 sSlicing never copies. A subslice is a header pointing into the same backing array, and Go's collector reclaims an array only when nothing references any part of it - there is no notion of a partially live allocation. So every retained 32-byte record pins its entire 4 MiB chunk, and the live heap grows with the number of *records*, not with their size. A heap profile makes it obvious: `inuse_space` attributes the memory to the `make([]byte, 4<<20)` in the read path, while the retaining code is the map or queue holding the records. The fix is to copy what you keep: `bytes.Clone(rec)` or `append([]byte(nil), rec...)`. Note that the three-index form `chunk[a:b:c]` does *not* help here - it caps capacity to stop appends clobbering the array, but the pointer, and therefore the retention, is unchanged.
code
go · 9 linesfunc lastRecord(r io.Reader) ([]byte, error) {
chunk := make([]byte, 4<<20)
n, err := io.ReadFull(r, chunk)
if err != nil {
return nil, err
}
rec := chunk[n-32 : n] // shares the whole 4 MiB array
return bytes.Clone(rec), nil
}go deeper
Know the core fact: a subslice points into the same array as the original and copies nothing. Keeping a small piece keeps the whole thing.
Explain that the collector reclaims whole allocations, so retention scales with how many chunks are pinned, and name the copying fixes: bytes.Clone, slices.Clone, or append starting from a nil slice.
Demonstrate the diagnosis: read inuse_space as an allocation site rather than a culprit, check cap against len on the retained value, and know that the three-index slice fixes aliasing rather than retention.
Turn it into a standing rule: data crossing a lifetime boundary is cloned, and buffer reuse is never combined with handing out subslices. Decide where that rule is enforced - review checklist, a capacity assertion in tests, or an API that returns owned values.
## The mechanism A slice expression like `chunk[off : off+32]` produces a new three-word header: a pointer to `&chunk[off]`, length 32, capacity `cap(chunk)-off`. It allocates nothing and copies nothing. The bytes still live in the array `chunk` describes. Go's garbage collector works at the granularity of the **allocation**, not the byte. An object is live if any reachable pointer points anywhere inside it. There is no way to free the 4 MiB minus the 32 bytes you care about: the collector cannot split an allocation, and Go's collector is non-compacting, so it will not relocate the 32 bytes elsewhere either. One surviving subslice keeps the whole block. That gives the failure its characteristic shape. Retention scales with the **count** of retained records rather than their total size. Ten thousand 32-byte records - 320 KB of data anyone would call negligible - hold up to 40 GB if each came from a different chunk. If several records come from the same chunk the arithmetic is kinder, which is exactly why the bug looks intermittent and load-dependent. ## Confirming it rather than guessing The cheap confirmation is a heap profile taken while the process is fat, read as `inuse_space`. The allocation site it blames is the `make([]byte, 4<<20)` in the read loop, which is confusing at first glance: that buffer is obviously short-lived, the code says so. The profile is telling you the truth - the allocation happens there, and something else is keeping it alive. The retaining reference is wherever the records are stored: a map, a channel with a deep buffer, a batch slice, a struct field. A second confirmation that costs nothing and belongs in the test suite: assert the capacity of what you retained. A record that was genuinely copied out has a small capacity, typically its own length; a record that is still a window into a chunk reports a capacity in the millions. `cap(rec)` is the cheapest possible retention check, it is deterministic, and it will fail the moment someone reintroduces the alias. In review, `cap` far exceeding `len` on a long-lived slice is the smell to name. ## The fix Copy what you keep, at the boundary where the record's lifetime becomes longer than the chunk's: ```go keep := bytes.Clone(rec) // Go 1.20+ keep := append([]byte(nil), rec...) // same effect, no import keep := slices.Clone(rec) // generic form, Go 1.21+ ``` All three allocate a new, exactly-sized array and copy the elements, so the chunk becomes unreachable as soon as the loop moves on. The cost is one small allocation per retained record, which is precisely the trade you want: many small live objects instead of a few enormous ones. **What does not fix it:** - `rec[:len(rec):len(rec)]`. The three-index (full slice) expression sets the capacity of the result, which stops a later `append` on `rec` from writing into the rest of the chunk. That is a real hazard and a real fix for *aliasing*, but the pointer is unchanged, so the chunk is still pinned. Conflating the two is the most common wrong answer to this question. - Setting `chunk = nil` after the loop. The retained record still points into the array; nilling one header does not matter. - Calling `runtime.GC()`. The array is reachable, so it is not garbage. ## Where else this bites The same shape appears whenever a small piece is carved out of a big allocation and outlives it: - `bufio.Scanner.Bytes()` returns a slice into the Scanner's own internal buffer, which the next `Scan` may overwrite. Storing that slice is both a retention bug and a correctness bug; the documented remedy is to copy, or to use `Text()`, which returns a string and therefore copies. - A parser that returns fields as subslices of the input document: fine for a request-scoped result, a leak if the fields go into a cache. - `s = s[:0]` to reuse a buffer keeps every element the array still holds. If the element type contains pointers, those pointees stay live too - a slice of pointers truncated to zero length still pins everything it held, which is why the careful reuse idiom clears the elements first. ## The reviewer's rule The rule worth writing down, because it turns a subtle memory question into a mechanical check: **a slice that outlives the buffer it was cut from must be copied.** Reviewers can apply that to a diff without profiling anything - look for a subslice being stored in a field, a map, or a channel that survives the read loop, and ask whether it was cloned. The performance objection ("an extra allocation per record") is answerable with numbers, and in this shape the numbers are never close. ## What to say in an interview "Slicing shares the array, and the collector frees whole allocations only. A 32-byte record pins its whole 4 MiB chunk, so live heap tracks record count, not record size. Heap profile blames the read buffer's allocation site; the fix is `bytes.Clone` at the point the record starts outliving the chunk. A three-index slice would not help - that caps capacity, it does not detach the pointer."
- How would you write a test that fails if someone reintroduces the alias?Assert the capacity of the retained record: after the parse, `if cap(rec) != len(rec) { t.Fatalf(...) }`. A cloned record has a capacity equal to its own length; a window into a 4 MiB chunk reports millions. It is deterministic, needs no profiler, and names the defect precisely when it fails.
- Would reusing one chunk buffer across reads instead of allocating per read solve this?It removes the allocation churn but makes the correctness worse: the retained records now alias a buffer that the next read overwrites, so the data silently changes underneath them. Buffer reuse and retaining subslices are incompatible. Copy at the boundary first; then reusing the chunk is safe and cheap.
- Does s = s[:0] release the memory the slice was holding?No. The backing array is unchanged and still fully allocated, and if the element type contains pointers, every pointee the array still holds stays reachable and live. Truncating is for reuse, not for release. To let the elements go, clear them first (`clear(s)` before reslicing) or drop the slice entirely.
- Why does a heap profile point at the read loop rather than at the code holding the records?A heap profile records the stack where memory was *allocated*, not where it is retained. inuse_space therefore blames `make([]byte, 4<<20)`. Reading it correctly means treating that site as the victim and hunting for the reference that keeps it reachable - which is why a capacity assertion on the retained value is often the faster diagnosis.
saying these in an interview costs you the question
- Believes slicing copies the elements out
- Thinks the collector can free the unused part of an array
- Offers the three-index slice as the fix for retention
- Suggests setting the chunk variable to nil
- Blames the profile's allocation site as the leaking code