Uncategorized

Memory management under the hood

Memory for a programming language can be allocated via the stack or the heap. Allocation on the stack happens in contiguous blocks of memory. When variables go out of scope these are deallocated automatically. Stack allocation is faster but stack size is limited.

Heap does not have a single block of memory but a set of free regions. Hence it can be expanded as needed. Since memory with the heap is fragmented, access is slower.

There is also the fixed sized segments such as data segment and code segment. Data segment normally contains global variables. Code segments contain the code instructions and constant values.

Traditionally, function parameters and local variables are located on the stack. and dynamic memory allocation is done on the heap.

Memory management in Go

Memory allocation in Go is a bit ambiguous. Go manages its own memory allocation and garbage collection. The language specification does not clearly draw a line about what will be stored where. If more space is needed it will accommodate that.

The initial stack size for a goroutine is only 2kB. Additional memory is allocated as needed. The Go compiler sets the default maximum stack to 1GB on 64-bit systems and 250 MB on 32-bit systems

To its credit, Go prefers allocation on the stack. But stack memory is limited and stack allocation has to be decided at compile time. If the size of the data could change at runtime, heap allocation will happen.

Go uses an optimization technique called escape analysis. The main idea is:

  • If a lifetime of the variable can be determined at compile time, it will be allocated on the stack.
  • If a value of a variable is shared or can escape outside a function scope, it will be allocated on the heap.

To find out what will escape to the heap in your code, use gcflags option:

go build  -gcflags '-m'
or
go tool compile -m test.go

Note that passing -m multiple times will give you a more verbose response.

There are some obvious escape patterns:

E.g.

func foo() *string{
a := "hello world"
return &a
}
go tool compile -m hello.go
command-line-arguments
./hello.go:9:6: can inline foo
./hello.go:6:17: inlining call to foo
./hello.go:6:17: foo() escapes to heap
./hello.go:6:17: &a escapes to heap
./hello.go:6:17: moved to heap: a
./hello.go:11:9: &a escapes to heap
./hello.go:10:2: moved to heap: a

This is a not so obvious example where memory escapes to heap

func main() {
    a := "hello"
   fmt.Println(a)
}

$go tool compile -m hello.go
hello.go:7:13: a escapes to heap
hello.go:7:13: main … argument does not escape

$go tool compile -m -m hello.go
hello.go:5:6: cannot inline main: function too complex: cost 89 exceeds budget 80
hello.go:7:13: a escapes to heap
hello.go:7:13: from … argument (arg to …) at hello.go:7:13
hello.go:7:13: from *(… argument) (indirection) at hello.go:7:13
hello.go:7:13: from … argument (passed to call[argument content escapes]) at hello.go:7:13
hello.go:7:13: main … argument does not escape

This showed that vara escapes to the heap because it is passed as a function argument to a function that takes a variadic argument. Had I passed a to a function that took just a string it would stay on the stack.

Per example below, if I pass a reference to a function, it does not escape to the heap but returning a reference will cause it to escape to the heap.

func main() {
    x := 2
    
    a, b := hello.AddTwo(&x)

    a = a + 2
    *b = *b + 2
}

func AddTwo(a *int) (int, *int) {
    b := 2
    return *a + 2, &b
}

./test.go:12:6: can inline main as: func() { x := 2; a, b = hello.AddTwo(&x); a = a + 2; *b = *b + 2 }
./test.go:14:22: inlining call to hello.AddTwo func(*int) (int, *int) { var hello.b·4 int; hello.b·4 = ; hello.b·4 = int(2); return hello.a + int(2), &hello.b·4 } ./test.go:20:6: can inline AddTwo as: func(int) (int, *int) { b := 2; return *a + 2, &b }
./test.go:14:23: main &x does not escape
./test.go:14:22: main &hello.b·4 does not escape
./test.go:22:17: &b escapes to heap
./test.go:22:17: from ~r2 (return) at ./test.go:22:2
./test.go:21:2: moved to heap: b
./test.go:20:13: AddTwo a does not escape
~/sources/src/github.com/mariadesouza/scratch $

There is no easy way to determine all the memory allocation within your code. For optimization, here are some guidelines:

  • Re-use variables where possible.
  • Run the compile tool with -m to inspect and if time permits, rewrite the code when the variable is detected as escaped to heap.
  • Preallocate memory for arrays if boundary is known rather than use a slice.