Memory, Escape Analysis and the Collector
Go has a garbage collector and pointers, and no new/delete distinction to
worry about: you take an address and the compiler works out whether the value
can live on the stack or must go on the heap. That decision is called escape
analysis, and it is the one piece of Go memory behaviour worth understanding.
package main
import "fmt"
type Point struct{ X, Y int }
// Does not escape: the value is copied into the return, so it can live in the
// frame and disappear when the function returns.
func stackPoint() Point {
p := Point{1, 2}
return p
}
// Escapes: the caller gets a pointer, so the value must outlive this frame.
func heapPoint() *Point {
p := Point{1, 2}
return &p // legal in Go, unlike C - the compiler moves it to the heap
}
func main() {
a := stackPoint()
b := heapPoint()
fmt.Println(a, *b)
// `go build -gcflags=-m` prints the decisions:
// ./main.go:14:9: &p escapes to heap
// which is how you find an allocation you did not expect.
fmt.Println("build with -gcflags=-m to see what escapes")
}
Returning a pointer to a local is safe in Go - the compiler notices and allocates accordingly. That removes a whole class of C bug, at the cost of an allocation you might not have intended.
What keeps memory alive
An object lives while something can reach it. Go's characteristic leaks are a slice that holds a much larger array, and a goroutine that never finishes.
package main
import (
"fmt"
"runtime"
)
func main() {
var stats runtime.MemStats
// A small slice of a big array keeps the WHOLE array alive.
big := make([]byte, 10_000_000)
for i := range big {
big[i] = byte(i)
}
small := big[:10]
big = nil // the variable is gone; the array is not
runtime.GC()
runtime.ReadMemStats(&stats)
fmt.Printf("holding %d bytes through a 10-byte slice: heap %dMB\n",
cap(small), stats.HeapAlloc/1_000_000)
// copy() breaks the link, and the 10MB becomes collectable.
independent := make([]byte, len(small))
copy(independent, small)
small = nil
runtime.GC()
runtime.ReadMemStats(&stats)
fmt.Printf("after copying out: heap %dMB\n", stats.HeapAlloc/1_000_000)
fmt.Println(len(independent))
}
The other one - a goroutine blocked forever on a channel - was the last lesson's subject, and it is worth repeating here: a leaked goroutine holds its stack and everything its closure references.
Value or pointer: the decision you make constantly
package main
import "fmt"
type Small struct{ A, B int }
type Large struct{ Data [1024]int }
// A small struct is cheaper to copy than to chase a pointer for.
func sumSmall(s Small) int { return s.A + s.B }
// A large one is not - and a pointer also avoids a copy per call.
func sumLarge(l *Large) int {
total := 0
for _, v := range l.Data {
total += v
}
return total
}
func main() {
small := Small{1, 2}
large := &Large{}
large.Data[0] = 42
fmt.Println(sumSmall(small), sumLarge(large))
// The rules of thumb, in order of importance:
// 1. Use a pointer if the method must modify the receiver.
// 2. Use a pointer if the struct contains a sync.Mutex or similar - a
// copy of a mutex is a different mutex, and a silent bug.
// 3. Use a pointer for large structs; a value for small immutable ones.
// 4. Be consistent within a type: mixing receivers confuses everyone.
fmt.Println("copying a struct with a mutex is the one that bites silently")
}
The collector, and what it asks of you
Go's collector is concurrent and non-generational: it runs alongside your program and aims for sub-millisecond pauses, at the cost of doing more total work than a generational collector would.
package main
import (
"fmt"
"runtime"
)
func main() {
var before, after runtime.MemStats
runtime.ReadMemStats(&before)
// Allocating in a loop. This is the cheap case - the collector is built
// for it - but it is not free, and it is the first thing to look at when
// a Go program is slower than it should be.
total := 0
for i := 0; i < 1_000_000; i++ {
s := fmt.Sprintf("item-%d", i) // one allocation per iteration
total += len(s)
}
runtime.ReadMemStats(&after)
fmt.Println("total length", total)
fmt.Printf("allocations: %d, total allocated: %dMB, GC cycles: %d\n",
after.Mallocs-before.Mallocs,
(after.TotalAlloc-before.TotalAlloc)/1_000_000,
after.NumGC-before.NumGC)
fmt.Println("GOGC (default 100) trades heap size against CPU;")
fmt.Println("GOMEMLIMIT (1.19+) puts a soft ceiling on the heap.")
}
Do not call runtime.GC() in application code - it is a hint at best and a
pause you asked for at worst. The two knobs that matter in production are
GOGC and GOMEMLIMIT, and both belong in deployment configuration rather
than in the program.
sync.Pool, for the allocation you genuinely cannot avoid
package main
import (
"bytes"
"fmt"
"sync"
)
var buffers = sync.Pool{
New: func() any { return new(bytes.Buffer) },
}
func render(name string) string {
buf := buffers.Get().(*bytes.Buffer)
defer func() {
buf.Reset() // always reset before putting it back
buffers.Put(buf)
}()
buf.WriteString("hello, ")
buf.WriteString(name)
return buf.String()
}
func main() {
var wg sync.WaitGroup
results := make([]string, 4)
for i := 0; i < 4; i++ {
wg.Add(1)
go func() {
defer wg.Done()
results[i] = render(fmt.Sprintf("worker %d", i))
}()
}
wg.Wait()
for _, r := range results {
fmt.Println(r)
}
fmt.Println("a pool is for hot paths with measurable allocation - not a default")
}
What to take away
- Escape analysis decides stack or heap; returning a pointer to a local is safe here.
go build -gcflags=-mtells you what escaped, and why.- A slice of a large array keeps the whole array alive -
copybreaks the link. - Pointer receiver to modify, for large structs, and always when a mutex is involved.
- The collector is concurrent and non-generational; tune with
GOGC/GOMEMLIMIT, notruntime.GC(). sync.Poolis for a measured hot path, and alwaysResetbeforePut.