Stack, Heap and Escape Analysis

The Go compiler uses escape analysis to determine which variables should be allocated on the stack of a goroutine and which variables should be allocated on the heap.

  • The way a variable is used - not declared - determines whether it lives on the stack or the heap.

  • Sharing up (returning pointers) typically escapes the Heap.

  • Go encourages the use of value types, which are allocated on the stack, as they don't require garbage collection.

The compiler will try to allocate a variable to the local stack frame of the function in which it is declared. However, it is also able to perform escape analysis: if it cannot prove that a variable is not referenced after the function returns, then it allocates it on the heap instead.

Output: 42

package main

import (
    "fmt"
)

func f() *int {
    a := 42
    return &a
}

func main() {
    defer profile.Start(profile.TraceProfile, profile.ProfilePath(".")).Stop()
    fmt.Printf("%d", *f())
}

The heap footprint increases with time. At a certain point, the GC runs and cleans up the heap, before it starts growing again.

We can rewrite the program so that there is no reference to a outside of the stack frame, then the variable doesn’t escape to the heap.

package main

import (
    "github.com/pkg/profile"
)

func f() int {
    a := 42
    return a
}

func main() {
    defer profile.Start(profile.TraceProfile, profile.ProfilePath(".")).Stop()
    for i := 0; i < 1000000; i++ {
        f()
    }
}

// go build -o ./main -gcflags=-m ./main.go
// # command-line-arguments
// ./main.go:7:6: can inline f
// ./main.go:15:4: inlining call to f
// ./main.go:13:21: ... argument does not escape

a does not escape to the heap, since it is not modified “above” f’s stack frame, (only “below”, in g’s).

package main

func g(a *int) {
    *a++
}

func f() int {
    a := 42
    g(&a)
    return a
}

func main() {
    for i := 0; i < 1000000; i++ {
        f()
    }
}

// go build -o ./main -gcflags=-m ./main.go
// # command-line-arguments
// ./main.go:9:6: can inline g
// ./main.go:13:6: can inline f
// ./main.go:15:3: inlining call to g
// ./main.go:22:4: inlining call to f
// ./main.go:22:4: inlining call to g
// ./main.go:9:8: a does not escape

If we execute g in a goroutine, we find that a escapes to the heap.

package main

func g(a *int) {
    *a++
}

func f() int {
    a := 42
    go g(&a) // yes, this is a race condition, but this is just a demo :)
    return a
}

func main() {
    for i := 0; i < 1000000; i++ {
        f()
    }
}

// go build -o ./main -gcflags=-m ./main.go
// # command-line-arguments
// ./main.go:3:6: can inline g
// ./main.go:3:8: a does not escape
// ./main.go:8:2: moved to heap: a

This is because each goroutine has its own stack. As a result, the compiler cannot guarantee that f’s stack hasn’t been popped (invalidating a) when g accesses it. Therefore, the variable must live on the heap.

StackHeap

space for the execution of thread; when a function in called, a block (stack frame) is reserved on top of the stack for local variables and some bookkeeping data; when a function returns, the block becomes unused and can be used the next time any function is called

requires manual housekeeping of what memory is to be reserved and what is to be cleaned the memory allocator will perform maintenance tasks such as defragmenting allocated memory or garbage collecting

initial stack memory allocation is done by the OS at compile time

allocated at run time

The function main has its local variables n and n2. main function is assigned a stack frame in the stack. Same goes for the function square.

Now as soon as the function square returns the value, the variable n2 will become 16 and the memory function square is NOT cleaned up, it will still be there and marked as invalid (unused).

Now when the Println function is called, the same unused memory on stack left behind by the square function is consumed by the Println function.

Resources:

Last updated