skip to content

A log shipper keeps each parsed line as a subslice of the 1 MB buffer it was read from, and heap use climbs steadily. Why?

level: seniorimportance: should knowfreq 46%

answer

  1. the collector frees arrays, not windows
  2. a small window pins a big allocation
  3. live bytes far exceed retained lengths
  4. the profile blames the read site
  5. copy what you intend to keep

basics

~20 s

A slice keeps its whole backing array alive, not just the elements it exposes. Each retained line pins its entire 1 MB buffer, so the heap grows with buffers referenced. Copy kept bytes into right-sized slices.

solid answer

~50 s

Go's collector frees an allocation only when nothing references it, and a slice references the whole backing array — its length and capacity are just a window. So a 200-byte line held as `buf[start:end]` keeps all 1 MB of `buf` reachable, and holding a thousand such lines pins a gigabyte while the program appears to hold 200 KB of data. A heap profile makes it obvious: the `inuse_space` view attributes the live memory to the allocation site of the read buffers, not to the line-parsing code, and the retained bytes far exceed the sum of line lengths. The fix is to copy what you keep — allocate a slice of exactly the line's length and `copy` into it — so each buffer becomes collectable once its chunk is parsed. Capping capacity with a full slice expression does not help: it prevents clobbering, not retention.

code

go · 7 lines
go
// pins the whole read buffer for as long as the line lives
batch = append(batch, buf[start:end])

// keeps only the bytes needed; buf becomes collectable
line := make([]byte, end-start)
copy(line, buf[start:end])
batch = append(batch, line)

go deeper

for a junior

Be ready to state the underlying rule: a slice refers to a whole backing array, so keeping any part of a large buffer keeps all of it in memory.

for a middle

Explain the fix precisely — allocate a slice of exactly the needed length and copy into it, and say why copy consults the destination's length rather than its capacity.

for a senior

Show the diagnosis: read live bytes rather than cumulative allocations, recognise that the profile names the allocation site rather than the retaining code, and rule out the non-fixes before changing anything.

for a principal

Own the API convention behind it: which packages may return views into reusable buffers, how that contract is documented, and where you accept a copy per retained item as the price of a lifetime people can reason about.

## The mechanism A slice value is a window: a pointer into a backing array, a length and a capacity. Reachability in Go is by allocation, not by window. If any slice, anywhere, points into an array, that entire array is live and the garbage collector will not reclaim any of it. There is no way to free "the part you are not using" — the collector is not compacting, and it has no notion of a partially live allocation. So a log shipper that reads a 1 MB chunk, splits it into lines, and keeps `buf[start:end]` for the lines it cares about has, from the collector's point of view, kept the megabyte. Retained memory grows with the number of *buffers* still referenced, not with the number of *bytes* the program logically holds. One surviving line per buffer is enough to pin the buffer, which is what makes this leak-shaped rather than merely wasteful: a slow-growing set of retained lines drags an unbounded number of buffers along with it. ## Recognising it at 3am The symptom is a heap that grows with retained-object count and never comes back down after a GC cycle, on a program whose own accounting says it is holding very little. Take a heap profile and read the `inuse_space` view — the live bytes, not the cumulative `alloc_space` allocations. The signature of this bug is a mismatch between *where* the memory was allocated and *what* is keeping it alive: the profile blames the read path (the site that allocated the buffers), because that is where the surviving allocations were made, while the code that actually holds the references is the parsing or batching path. A profile of allocation counts looks entirely normal; only the live-bytes view shows a small number of huge, ancient allocations that will not die. Two confirmations are cheap. First, compare the live bytes attributed to buffer allocation against the number of items you believe you are retaining multiplied by their average length; a ratio of hundreds to one is the tell. Second, look at the retaining structure: if your batch, cache or queue holds `[]byte` values you never copied, you have found it without needing any further tooling. ## The fix Copy what you keep, at the moment you decide to keep it: ```go line := make([]byte, end-start) copy(line, buf[start:end]) batch = append(batch, line) ``` `copy` transfers `min(len(dst), len(src))` elements and returns that count, so the destination's *length* must be right — a destination made with `make([]byte, 0, n)` copies nothing. `append([]byte(nil), buf[start:end]...)` is an equivalent one-liner that allocates a fresh array because a nil slice has no capacity. If the value is textual and will not be mutated, converting to `string` copies too, and gives you an immutable value that is cheap to share. After the fix, each retained line owns a small allocation of its own, and every read buffer becomes garbage as soon as parsing of that chunk finishes — which also means the buffers can be reused rather than re-read. ## What does not fix it - **Capping capacity** with a full slice expression, `buf[start:end:end]`. That controls whether a later append can write into the buffer; it changes nothing about reachability. The array is still pointed at, so it is still live. - **Setting the buffer variable to nil** after parsing. The variable is not what keeps the array alive; the surviving subslices are. - **Calling the collector explicitly.** The memory is genuinely reachable; no amount of collection pressure can reclaim it, and forcing collection only burns CPU while the heap stays flat. - **Shrinking the read size.** Smaller buffers reduce the amplification factor but not the shape of the bug, and they cost more syscalls. ## The generalisation worth stating This is the retention half of a rule that has a mutation half. Handing out a subslice of a buffer you own shares two things you may not have intended: the *contents*, which the holder can read and write and which your next write can change under them; and the *lifetime*, which is now the buffer's, not the window's. Any API that returns a view into a reusable buffer should either be documented as valid only until the next call — the convention several stdlib readers follow, where the bytes handed back may be overwritten by the next read and must be copied to be retained — or should copy on the way out. Choosing to copy costs one allocation per retained item and buys you a lifetime you can reason about; on a hot path where nothing is retained, the view is the right call. What you cannot do is return the view and then keep it.

  • Which view of a heap profile shows this, and what does it point at?
    The live-bytes view, inuse_space, rather than the cumulative alloc_space one. It attributes the memory to the site that allocated the read buffers, because that is where the surviving allocations came from — not to the parsing code that actually holds the references. The mismatch between allocation site and retaining code is the diagnostic signature.
  • Would capping the subslice's capacity with buf[start:end:end] fix the growth?
    No. Capacity decides whether a later append writes in place or allocates; it has nothing to do with reachability. The slice still points into the same array, so the whole buffer stays live. Only allocating a separate, right-sized slice and copying the bytes lets the buffer be collected.
  • How does copy behave if the destination is made with make([]byte, 0, n)?
    It copies nothing and returns 0. copy moves min(len(dst), len(src)) elements and consults length, not capacity, so a zero-length destination is a no-op no matter how much room it has. Allocate with make([]byte, len(src)) — or use append onto a nil slice, which sizes the result for you.
  • Some readers document that the bytes they return are only valid until the next read. What does that oblige a caller to do?
    Copy them before keeping them. Such a reader hands back a window into a buffer it reuses, so the contents can be overwritten by the next call and, if the caller does retain the window, the buffer's lifetime is extended indefinitely. Retaining anything from that kind of API means copying, or converting to a string, at the point of retention.

It is like keeping one sentence by keeping the whole newspaper. You wanted forty words, but nothing can be recycled until you photocopy the sentence and throw the paper away.

saying these in an interview costs you the question

  • Says the GC frees the unreferenced part of an array
  • Blames the parsing code because the profile names the reader
  • Proposes capping capacity to release memory
  • Suggests forcing a collection to reclaim reachable memory
  • Sets the buffer variable to nil and expects the array to go