Mastering Go Pointers: Function Frames and Memory Management

December 15, 2024
9 min read
By __init__abs

Table of Contents

This is a list of all the sections in this post. Click on any of them to jump to that section.

index

Mastering Go Pointers: Function Frames and Memory Management

Introduction

Let’s be honest: pointers can be a tough nut to crack. Misusing them can lead to frustrating bugs and slow performance, especially in concurrent or multi-threaded programs. That’s why many languages shield developers from dealing with pointers altogether. However, if you’re coding in Go, you can’t escape them. Mastering pointer is essential for crafting elegant, efficient code in Go.

Function Frames

In Go programming, functions execute within a defined function frame, each maintaining their distinct memory space. These frames allow the functions to operate autonomously within their designated context, facilitating smooth flow control. While a function can readily access memory within its frame, venturing beyond its frame necessitates indirect access. To tap into the memory within a different function frame, the memory in question must be shared with the function via frame pointer. Grasping the intricacies and constraints imposed by these function frames is paramount.

When a function is invoked within another function, there is a transaction that occurs between the calling and the called function frame. If the function call requires data, then the data must be passed to the other frame in “pass by value” fashion.

Pass by value (also known as pass-by-copy) is the argument passing technique utilized in the Go programming language. This technique permits various forms of arguments in the call, such as constants, variables, and complex expressions, while also ensuring the immutability of arguments. It achieves this by guaranteeing that the function receives a copy of the data. Consequently, the function can modify the data without impacting the original data.

package main
 
import "fmt"
 
func increment(val int) {
	// Increment the value of val
	val++
 
	// Printing the value of the val variable
	fmt.Println("Val value:", val)
 
	// Printing the address of the val variable
	fmt.Println("Val Address:", &val)
 
}
 
func main() {
	// Declaring variable of type int
	var counter int
 
	// Setting the value of the counter variable to 1
	counter = 1
 
	// Printing the value of the counter variable
	fmt.Println("Counter value:", counter)
 
	// Printing the address of the counter variable
	fmt.Println("Counter Address:", &counter)
 
	// Pass counter as an argument to the increment function
	increment(counter)
 
	// Printing the value of the counter variable
	fmt.Println("Counter value after increment:", counter)
 
	// Printing the address of the counter variable
	fmt.Println("Counter Address after increment:", &counter)
 
}

When executing a Go program, the runtime initiates the main goroutine to commence executing all code, including that within the main function. A goroutine represents a path of execution assigned to an operating system thread for execution on one of the cores. Each goroutine is assigned an initial ~2KB block of contiguous memory, forming its stack space.

The stack serves as the physical memory location for each function frame. The image below illustrates the physical memory allocated to the main function frame on the stack.

Main Function Frame
Figure 1 - Main Function Frame

In Figure 1, a segment of the stack is delineated for the main function, forming what is known as a “Function Stack Frame”. This frame serves to demarcate the main function’s boundary within the stack and is created during the execution of the function call. Additionally, within the main frame, memory for the counter variable is located at address 0xc000012028.

Function Calls

The main function calls the increment function as shown on line 31. A new function call means that the goroutine needs to create a new stack frame for increment function(called function). To successfully execute this function call, data needs to be passed across the stack frame and placed into the newly created stack frame as specified in the declaration of the increment function on line 5. In this specific case, an integer value(counter) is expected to be copied and passed during the call.

As the increment function can only read and write to memory locations within its own frame, it needed a variable val of type int to store and access its own copy of the counter value being passed. The passed value of counter is copied and passed into the newly created val variable inside the increment function frame.

Main and Increment Frames
Figure 2 - Main & Increment Frames

You’ll notice that the stack now comprises two frames: one for the main function and, beneath it, one for the increment function. Within the increment frame, the variable val holds the copied value of 1 passed during the function call. Positioned at address 0xc0000a2018, the val variable resides lower in memory, reflecting the sequential arrangement of frames along the stack. This ordering is merely an implementation detail devoid of significance. Crucially, the goroutine extracted the counter value from the main frame and duplicated it within the increment frame using the val variable.

The control executes all the lines under the increment function and the output on the terminal should look something like this:

Val value: 2
Val Address: 0xc0000a2018

Function Returns

After executing all the lines under the increment function, the control returns to the main function with a small change to the stack frames.

The stack frame associated with the increment function is now part of the garbage memory because the control has shifted to main function thereby making the main function frame the active frame. The memory that was framed for the increment function is left untouched.

It’s pointless to tidy up the memory of the returning function’s frame because there’s no way to know if that memory will be needed again. Hence, the memory remains unchanged. The stack memory for each frame is actually wiped clean during every function call. This cleaning process happens when values are initialized within the frame. Since all values are set to their default “zero value”(0 is the default value for the int type) during initialization, the stacks naturally tidy themselves up with each function call.

Frames after increment function returns
Figure 3 - Frames after increment func returns

The final output of the above code block on the terminal should look something like this:

Counter value: 1
Counter Address: 0xc000012028
Val value: 2
Val Address: 0xc0000a2018
Counter value after increment: 1
Counter Address after increment: 0xc0000a2010

Sharing Values

In case if it was important for the increment function to operate directly on the counter variable that exists inside the main function’s stack frame, pointers prove invaluable. They facilitate value sharing, enabling functions to operate on variables located outside their own stack frame.

Indirect Memory Access

The code block below performs a function call passing an address “by value”. The value of the counter variable from the main stack frame is shared with the increment function.

package main
 
import (
	"fmt"
)
 
func increment(val *int) {
	// Increment the value of val
	*val++
 
	// Printing the value of the val variable
	fmt.Println("Val value:", val)
 
	// Printing the address of the val variable
	fmt.Println("Val Address:", &val)
 
	// Printing the value the val pointer points to
	fmt.Println("Val Points To:", *val)
 
}
 
func main() {
	// Declaring variable of type int
	var counter int
 
	// Setting the value of the counter variable to 1
	counter = 1
 
	// Printing the value of the counter variable
	fmt.Println("Counter value:", counter)
 
	// Printing the address of the counter variable
	fmt.Println("Counter Address:", &counter)
 
	// Pass counter as an argument to the increment function
	increment(&counter)
 
	// Printing the value of the counter variable
	fmt.Println("Counter value after increment:", counter)
 
	// Printing the address of the counter variable
	fmt.Println("Counter Address after increment:", &counter)
 
}

The three interesting changes that were made to facilitate indirect memory access are as follows:

  1. On line 36, the code is not copying and passing the “value of” counter but instead the “address of” counter. ”&” operator extracts the address of the counter variable in the main function frame. As Go is a pass by value language, so we are still passing a value but just in form of an address.

  2. On line 7, *int highlights that the increment function expects a address of the variable(pointer) rather than the variable itself. Since the variable counter is of type int, it’s pointer type is *int.

Frame Stack after function call to increment
Figure 4 - Frame Stack after func call to increment
  1. On line 9, the * character in *val++ is acting as an operator and extracts the value that pointer is pointing to. The pointer variable allows indirect memory access outside of the function’s frame. The process of extracting the the value that pointer is pointing to is called dereferencing.
Frame Stack after executing line 9
Figure 5 - Frame Stack after executing line 9

The final output of the above code block on the terminal should look something like this:

Counter value: 1
Counter Address: 0xc000012028
Val value: 0xc000012028
Val Address: 0xc00004a028
Val Points To: 2
Counter value after increment: 2
Counter Address after increment: 0xc000012028

Key Takeaways

Understanding pointers and function frames in Go is crucial for:

  • Memory Management: Knowing how Go manages memory across function calls
  • Performance Optimization: Understanding when to use pointers vs values
  • Debugging: Identifying memory-related issues in your applications
  • Concurrent Programming: Writing safe concurrent code with proper memory sharing

Mastering these concepts will make you a more effective Go developer and help you write more efficient, maintainable code.

Conclusion

Pointers in Go might seem intimidating at first, but understanding function frames and memory management principles makes them much more approachable. Remember that Go’s pass-by-value nature means that pointers are your tool for sharing data between function frames efficiently.

By grasping these fundamental concepts, you’ll be well-equipped to write performance-critical Go applications and debug memory-related issues effectively.