Course outline

Go / Advanced: Safety, Performance, and Service Design / Lesson 17

Garbage Collection and Measured Performance

Reason about reachability, allocation pressure, and memory limits without confusing automatic memory management with bounded resource use.

About 5 min readContent reviewed
On this page

Lesson goals

  • Explain why reachable data and blocked goroutines remain live.
  • Separate allocation rate from retained heap and process memory.
  • Evaluate optimizations with correctness and representative measurements.

Reachability determines what can be reclaimed

Garbage collection reclaims memory that is no longer reachable according to runtime rules; it does not decide that a still-referenced cache entry is no longer useful. A tiny slice can retain a large backing array, and a blocked goroutine can retain its stack and referenced objects. Automatic memory management therefore does not prevent logical leaks.

Dropping one alias does not remove all other references. Clearing a variable is useful only when it changes the actual reachable graph for a meaningful lifetime. Do not promise immediate physical-memory return after assigning nil or calling runtime.GC. Collection, allocator reuse, and returning pages to the operating system are different events.

Copy a small result to detach its storage

The first program copies a one-byte view into independent storage. Mutating the original backing array then leaves the detached result unchanged. In a long-lived component, returning such a copy can prevent retaining a much larger input buffer. The demonstration prints value semantics, not a fabricated heap measurement or a guarantee about the moment GC runs.

Copying has a cost, so use it where lifetime and retained-memory requirements justify it. Restricting slice capacity alone does not detach the backing array. For nested reference-bearing elements, an outer copy may still retain other data; inspect the reachable graph instead of equating a short len with a small retained footprint.

go An independent small result
package main

import "fmt"

func main() {
	backing := make([]byte, 1024)
	backing[0] = 7
	view := backing[:1]
	detached := append([]byte(nil), view...)
	backing[0] = 9
	fmt.Println("view", view[0], "detached", detached[0], "length", len(detached))
}
Expected output
view 9 detached 7 length 1

The view still shares the original array, while the copied byte remains seven.

Measure the right memory quantity

Allocation rate describes newly allocated work over time; retained heap describes live reachable memory at a point or sampled profile view. Process resident memory also includes runtime structures, stacks, mappings, and other sources. A change can reduce one metric while increasing another. Report which quantity and interval a benchmark actually measures.

The second program preallocates a known result capacity and verifies output. It makes no claim that the runtime performed exactly a certain number of allocations. Compiler escape analysis and optimizations influence placement, and those implementation choices can change across versions. Benchmark and profile when an allocation claim matters.

go Preallocate a known bounded result
package main

import "fmt"

func squares(input []int) []int {
	output := make([]int, 0, len(input))
	for _, value := range input {
		output = append(output, value*value)
	}
	return output
}

func main() { fmt.Println(squares([]int{1, 2, 3, 4})) }
Expected output
[1 4 9 16]

The fixed inputs keep multiplication in range and produce four squares in input order.

Tune only after understanding the live workload

GOGC influences the collectorโ€™s memory-versus-CPU tradeoff, while GOMEMLIMIT sets a soft runtime memory limit in supported releases, including the baseline. It is not a hard process RSS ceiling and does not account for every external memory source. Setting an impossible limit can cause excessive GC work rather than make the application fit.

First bound caches, input sizes, queues, workers, and object lifetimes. Then measure live memory and allocation pressure before choosing runtime settings for a controlled deployment. Leave headroom for stacks, non-Go memory, and the operating environment. Do not copy tuning constants from an unrelated service or teach one value as universally optimal.

Preserve semantics before reducing cost

Use benchmarks with representative size and distribution, repeated trials, and correct result consumption. Compare throughput, latency distribution, allocations, and retained memory where relevant. An optimization that shares mutable data instead of copying can be faster while violating the APIโ€™s independence guarantee. Such a change is a correctness regression, not a free improvement.

sync.Pool is a reuse mechanism whose items may disappear; it is not a durable cache or an ownership protocol. Reusing buffers requires ensuring no caller still retains an alias. Avoid unsafe conversions and manual memory tricks until profiling shows a real need and a separate correctness argument exists. Prefer a clear algorithm and bounded lifetime over speculative micro-optimization.

Common errors and fixes

A one-byte result keeps a large input alive.

Why it happens
The result is a slice view into the original backing array.
How to fix it
Copy the required bytes when a detached lifetime is needed.
Prevent it next time
Review long-lived subslices and cache entries.

A memory limit is treated as an absolute OOM shield.

Why it happens
A soft runtime limit was confused with a hard process bound.
How to fix it
Bound application growth and account for external memory/headroom.
Prevent it next time
Measure the actual controlled deployment.

A pooled buffer changes after being returned to a caller.

Why it happens
The buffer was reused while an alias remained live.
How to fix it
Transfer ownership explicitly or copy before returning/reusing.
Prevent it next time
Test alias lifetime around pool boundaries.

Check your understanding

1. Why does reducing a slice from length one million to length one not prove its retained allocation became one element?

Show worked solution

The slice descriptor still refers to the same backing array, so reachability can retain that larger allocation. Length only governs visible indexing. Copy the needed element into independent storage if the result must outlive the input without retaining it. Confirm the real retention pattern with a representative heap profile rather than assuming immediate memory return.

2. A benchmark reports fewer allocations after returning shared slices. What additional checks are required before accepting the change?

Show worked solution

Verify the public mutation and lifetime contract, test callers that retain or modify results, and run race checks for concurrent access. Measure retained memory because shared views may keep larger buffers alive. A lower allocation count is useful only when correctness and other resource requirements remain satisfied under the actual workload.

Key takeaways

  • Automatic collection does not bound reachable memory or goroutine lifetimes.
  • Measure allocation, retention, and process memory separately.
  • Runtime tuning complements bounded design and cannot replace it.

Official references