logo
Published on

Stack or Heap? Going Deeper with Escape Analysis in Go for Better Performance

Authors
covers2-3

How Escape Analysis Affects Memory Allocation and Application Performance in Go

Recently, I decided to embark on a journey to learn Go, not only because it's the cool kid on the block in the world of programming languages (though that's definitely part of it), but also because of its widespread use in cloud infrastructure—a domain that has recently captured my interest. It's somehow a contrast to my usual stomping ground in JavaScript (let’s be honest, it’s nice to work with a “real” language for a change 😜). And indeed, as I explored the intricacies of Go, I realized that it's not just another language—it's a gateway to a whole new mindset (ahem ahem interfaces in Go 🙄). So, as I dusted off my blog to breathe some new life into it, I knew I wanted to explore a concept that is technical but not banal. That’s when I stumbled upon escape analysis.

Let's jump right into the heap of things, with no escape plan, and explore how escape analysis affects memory allocation and application performance in Go (so many pun intendeds in one sentence 🙈).

In general, when a function in Go creates a variable, the Go compiler decides whether the variable should be allocated on the stack or the heap. This process of "deciding" is called escape analysis, and as you can guess, it's happening at compile time (and not at runtime).

📟 How does it work?

  • Static Code Analysis: The Go compiler analyzes the function body at compile time to trace the lifetimes and usages of variables.
  • Escape Conditions: It checks if a variable is passed outside its scope, such as returning a pointer, storing it in global variables, or passing it to goroutines (Go's lightweight threads of execution).
  • Decision Making: Based on the analysis, the compiler decides whether a variable can be safely allocated on the stack (in Go there is a stack for each goroutine). If a variable doesn't escape (it remains within the scope of the function where it is defined and isn't returned or referenced outside), it is stack-allocated. Otherwise, it is allocated on the heap.

👊 Stack vs. Heap Allocation

In the table below, you can see a comparison between stack and heap allocation, highlighting the differences in memory management techniques:

AspectStack AllocationHeap Allocation
Allocation CriteriaIf the variable doesn't escape the scope of the function where it is defined (i.e., it is not returned or referenced outside), it can be safely allocated on the stack.If the variable escapes (for example, by being returned from the function or captured by a goroutine), it must be allocated on the heap to ensure that it remains accessible after the function returns.
PerformanceFaster due to simple pointer manipulation.Slower due to dynamic memory allocation.
Garbage Collection PressureNone. Memory is automatically reclaimed when function returns.May cause overhead due to garbage collection.
Memory EfficiencyGenerally more efficient due to cache locality and automatic deallocation.May be less efficient due to fragmentation and overhead.

💥 Impact on Go Development

Optimized Code: Knowing how escape analysis works can help you write more optimized Go code, consciously avoiding unnecessary heap allocations.

Better Performance Tuning: For performance-critical applications, understanding escape analysis enables finer control over memory usage and performance.

🤔 Does it matter?

"From a correctness standpoint, you don't need to know. Each variable in Go exists as long as there are references to it." Go's FAQ

It doesn't matter whether you know where your variables live, as far as the correctness of your program is concerned. Go ensures that your variables reside in the correct place. However, if you test your program with a profiler and it shows excessive heap allocation, then you might want to look into escape analysis to see if you can optimize your code.

🔮 How to Check Escape Analysis in my Go Code?

Go provides a -gcflags flag that allows you to pass flags to the Go compiler. You can use the -m flag to print escape analysis information for your code.

go build -gcflags "-m" main.go
# or "-m=2" for more detailed output

🧮 Code Speaks Louder

Starting simple, have a look at the following code snippet:

package main

import "fmt"

func main() {
	num := 3
	cubedValue := cube(num)
	fmt.Println(cubedValue)
}

func cube(base int) int {
	return base * base * base
}

When you run go build -gcflags "-m" main.go, apart from other output which doesn't concern us right now, you'll see the following line:

./main.go:8:14: cubedValue escapes to heap

This might seem counterintuitive since cubedValue is only used locally within the main function and is not passed by reference or returned. This allocation decision can be influenced by various factors:

  1. fmt.Println internally uses interfaces to handle different types of arguments (it returns Fprintln which is a function that takes an io.Writer interface and a variadic argument of type interface{}). When you pass a variable like cubedValue to fmt.Println, it must be stored in an interface value. Storing a value in an interface often causes it to escape to the heap because interfaces can be passed around and need to outlive the call stack of the function where they were created.
  2. The Go compiler might decide based on its internal optimization strategy that putting cubedValue on the heap is more efficient or safer considering how the memory is managed, especially when interfacing with system calls for output.
🧐 Where is base!? Since base is inlined, base essentially becomes part of the main function's scope during compilation.

It is treated as a local variable within main, and since cube(base) is computed and directly assigned to cubedValue, there's no separate mention of "base escaped to the heap".

Going one step further, let's look at a more complex example, using pointers:

package main

import "fmt"

type UserProfile struct {
	Name   string
	Status string
}

func main() {
	user := UserProfile{Name: "Albert", Status: "Active"}
	fmt.Println("Before update:", user)

	updateStatus(&user, "Inactive") // status is updated via a pointer
	fmt.Println("After update:", user)
}

func updateStatus(u *UserProfile, newStatus string) {
	u.Status = newStatus // directly modifies the user profile's status via a pointer
}

The updateStatusfunction accepts a pointer to UserProfile, allowing it to modify the original struct directly, which is efficient for larger data structures or when performance is a concern.

When you run go build -gcflags "-m" main.go, you'll see the following output:

./main.go:25:31: user escapes to heap
./main.go:28:19: u does not escape

The user address (&user) is passed to updateStatus. Therefore, the struct needs to be allocated on the heap to ensure it remains accessible beyond the scope of the main function.

u points to a UserProfile struct that is used only within localized contexts and the compiler can "see" that its lifetime is well contained, so it allocates it on the stack

💡 keep in mind that if the pointer u, or the UserProfile structure it refers to, are used across multiple functions, shared among goroutines, or stored in variables that are globally accessible, then it's likely that the data will need to be allocated on the heap.

A familiar example for that is the usage of request *http.Request in Go web servers. There you can see a different behavior in the allocation of the request object. Since the request object is passed around to multiple functions and is used in various contexts, it is allocated on the heap to ensure its availability across the request lifecycle.

func handler(w http.ResponseWriter, r *http.Request) {
    // handle the request
}

Things get more nasty when you return a pointer.

package main

import "fmt"

func main() {
	maxConnections := getConfigValue()
	fmt.Println("Max database connections allowed:", *maxConnections)
}

func getConfigValue() *int {
	defaultMaxConnections := 100
	return &defaultMaxConnections
}

The getConfigValue function returns a pointer to an integer. Then, in the main function, it is dereferenced to get the actual value.

When you run go build -gcflags "-m" main.go, you'll see the following output:

./main.go:44:51: *maxConnections escapes to heap
./main.go:49:2: moved to heap: defaultMaxConnections

*maxConnections refers to a value that was initially on the stack of the getConfigValue function, but due to the necessity of maintaining its availability after the function ends, it was moved to the heap.

🚚 Which brings me to the next point: what the heck is "moved to heap"?

When you see the message "moved to heap" in the escape analysis output, it means that the variable was initially allocated on the stack but was moved to the heap.

The necessity of returning the address of defaultMaxConnections from getConfigValue(), means it must be accessible even after the function exits. Thus, the compiler "decides" to allocate it on the heap to ensure its availability throughout the lifetime of the pointer.

This is different from initially planning to allocate on the heap. Instead, it's more of a dynamic decision based on how the variable is used.

💾 io.Reader and io.Writer

I mentioned before that using interfaces can cause variables to escape to the heap. This is particularly relevant the way the io.Reader and io.Writer interfaces were designed in Go.

type Reader interface {
	Read(p []byte) (n int, err error)
}

type Writer interface {
	Write(p []byte) (n int, err error)
}

Both io.Writer and io.Reader interfaces require the caller to provide a pre-allocated buffer ([]byte slice). This design lets the caller manage memory allocation, which can optimize performance by reusing buffers and minimizing garbage collection.


🔖 Cheat Sheet

  • Function variables are allocated on the stack only if the Go compiler can prove that they won't be needed after the function returns. If a variable might be needed after the function returns, it's allocated on the heap to ensure it remains accessible, avoiding dangling pointers.
  • when the compiler doesn't know the size of the variable at compile time, it allocates it on the heap. This is common for slices, maps, and channels,
  • Very large local variables, like big slices, are typically allocated on the heap instead of the stack to prevent stack overflow and manage memory more efficiently.
  • Variables that are stored in interfaces, Maps, Chanels, Slices and Strings are usually allocated on the heap.
  • Anonymous functions and variables that are captured by a closure, are usually allocated on the heap
  • Passing pointers or references to other functions typically allows variables to stay on the stack, unless these variables need to be used after the calling function has returned.
  • Returning pointers or references from a function generally results in the variable being allocated on the heap. This ensures the variable remains accessible even after the function exits.

"Only if, usually, unless..." - I used these words because nothing is 100% acurate, and the best thing you can when you're unsure is to check the escape analysis output.


That's it. I hope you enjoyed this post. If you have any questions or comments, feel free to reach out to me on Twitter or LinkedIn.

bye-bye