Exploring Memory Management: A .NET Developer's Insights into Golang

Siddhartha SSiddhartha S
7 min read

Introduction

Over a decade ago, as I explored the intricacies of .NET memory allocation, it struck me as intuitive and elegant. The CLR efficiently manages the Managed Heap as a contiguous slice of memory, optimizing garbage collection—a brilliant concept that endures. Fast forward to today, as I explore Golang, I find my understanding of "elegance" expanding.

Memory management in Golang differs significantly from .NET, and for good reasons. Go doesn't adhere to the OOP paradigm like C#, necessitating a unique approach to memory management and garbage collection. While the differences between Go and .NET are numerous and worthy of separate discussion, this article focuses on memory allocation and garbage collection in these two robust technologies.

Background: Memory Collection in .Net

If you're reading this, you likely have some familiarity with .NET memory allocation and garbage collection. Let's briefly recap key features to set the stage for comparison:

  • Functions operate on the stack, where primitive types are declared.

  • Reference types instantiated during function execution are allocated on the managed heap.

  • Primitive types encapsulated by reference types are also allocated within the heap.

  • When a function completes execution and no variables reference the heap location, the memory is considered orphaned and eventually freed by the Garbage Collector.

This is a simplified overview of .NET Garbage Collection, omitting complexities like the Large Object Heap (LOH), yet it captures the essence of the process.

The Plight

Go's memory allocation is more complex. Unlike C#, where all classes are reference types and hence goes to Managed Heap, Go employs pointers. It may seem that using pointers in Go transforms a value type struct into a reference type, similar to .NET garbage collection.

Well, let's find out with an example:

To summarize the above code:

  • Line 17: The main method begins program execution.

  • Line 19: Declares the struct SmallStruct.

  • Line 20: Invokes changeSmallStruct(&smallStruct).

  • Line 21: Prints and verifies changes.

  • Line 23: Calls newSmallStruct().

  • Line 24: Prints and confirms initialization.

Here we discuss just the plight, and so I will just leave you with a question and its answer:

Question: Which instance of the two (smallStruct, anotherSmallStruct) will get allocated to where (stack, heap)? Remember both are pointers and hence references.

Answer: smallStruct → Stack, anotherSmallStruct→ Heap

Demystifying the plight

To understand what's happening, we will need to have a look at how things work in the stack as the program proceeds.

Below ascii art diagram would explain the stack operation and the explanation follows that:

[Line 17 Defines the main method and that's where the program execution will start from.]
Stack:
|----------------|
| main()         |
|----------------|

[Line 19: Declares the struct called SmallStruct.]
Stack:
|----------------|
| main()         |
| smallStruct    | <- Allocated on stack (Name: "Sid", City: "Mumbai")
|----------------|

[Line 20: Calling changeSmallStruct(&smallStruct). This is when a new stack frame is loaded.]
Stack:
|----------------|
| changeSmallStruct() |
| arg (pointer)  | <- Points to smallStruct in main()'s frame
|----------------|
| main()         |
| smallStruct    |
|----------------|

[After changeSmallStruct() completes]
Stack:
|----------------|
| main()         |
| smallStruct    | <- Modified (Name: "Siddhartha", City: "Bangalore")
|----------------|

[Line 23: Calling newSmallStruct()]
Stack:
|----------------|
| newSmallStruct() |
|----------------|
| main()         |
| smallStruct    |
|----------------|
Heap:
[SmallStruct]    <- Allocated on heap (Name: "James", City: "Panji")

[After newSmallStruct() returns]
Stack:
|----------------|
| main()         |
| smallStruct    |
| anotherSmallStruct | <- Pointer to heap-allocated SmallStruct
|----------------|
Heap:
[SmallStruct]    <- (Name: "James", City: "Panji")

[Program End]
Stack:
(empty)
Heap:
[SmallStruct]    <- Will be garbage collected

Explanation of the above ascii art diagram is as follows (you may would like to refer the explanation and the diagram together):

  1. In line 19, the memory for the struct SmallStruct gets allocated on stack and a pointer for that is created. The pointer is assigned to the variable “smallStruct”
  1. In line 20, the method changeSmallStruct gets called and a new stackFrame is loaded for that.

  2. In lines 12-15: The pointer gets passed to the method changeSmallStruct and this method works on the pointer by dereferencing it and then changing the fields that it is pointing to. Thus the memory in the stack frame of the main() method that the pointer is referencing to, gets updated (Field names get changed to Siddhartha and Bangalore respectively).

  3. The stack frame for the method changeSmallStruct() is unloaded and the stack memory is reclaimed.

  4. In line 23: New Stack frame gets loaded for newSmallStruct().

  5. In line 9: The same thing happens that happened in line 19. The memory for the struct SmallStruct gets assigned in the stack frame of newSmallStruct() method. And this pointer is returned to the stack frame of the main() method in line 23.

    💡
    The italicized line in the above point is not correct, but please read on!
  6. The next logical step would be to unload the stack frame for method newSmallStruct. But here comes the paradox. The actual memory that had been allocated for SmallStruct is in the stackframe that needs to be unloaded. The main method stack frame now has the reference of a memory that needs to get unloaded! That is why the go compiler in line 9, would not allocate the memory for the struct in the stack frame of newSmallStruct(). It would rather create it in the Heap because the stack frame that it would get created in needs to be unloaded with a pointer still referencing it from the main method.

  7. The main program finally exits and the memory for the SmallStruct {Name: “James”, City: “Panji”} heap will be garbage collected.

Phew, so certainly the memory allocation and garbage collection is done quite differently in Go!

Further

At this point there may be several questions popping in your head:

Is this it, is it the only way Golang decides to allocate memory?

The Go compiler uses a technique called escape analysis to determine whether a variable should be allocated on the heap or the stack. Generally, the Go compiler tries to allocate variables on the stack when possible, as it's more efficient.

Variables are typically allocated on the heap when:

  • They are too large for the stack

  • Their size is not known at compile time

  • They are shared with other goroutines

  • They outlive the function that created them.

You may refer to go documentation. Here are few links:

Why do we care where the memory allocation happens?

Most of the time we don’t and we shouldn't. But too much garbage collection can become a bottleneck for the application’s performance and being aware of how the memory allocation works lets you write better code that aligns well with the go mindset. As an example I would like to draw your attention to a go standard library method:

https://pkg.go.dev/io#Reader

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

Notice, the Read method is taking a byte slice and passing it down, rather than returning the byte slice as a return type. This is for the reasons discussed above.

How do we always know where the memory is getting allocated?

Actually, even seasoned golang devs can be tricked while predicting the memory allocation. So it's always better to use build tools to find the memory allocation of the objects getting created in the program. One such tool is the following:

go build -gcflags -m main.go

For the above demo program it would output the following:

./main.go:8:6: can inline newSmallStruct

./main.go:12:6: can inline changeSmallStruct

./main.go:17:6: can inline main

./main.go:20:19: inlining call to changeSmallStruct

./main.go:23:38: inlining call to newSmallStruct

./main.go:9:9: &SmallStruct{...} escapes to heap

./main.go:12:24: arg does not escape

./main.go:19:17: &SmallStruct{...} does not escape

./main.go:23:38: &SmallStruct{...} does not escape

You may add -m=2 to increase verbosity.

Conclusion

Memory management is fundamental to a programming language's efficiency and performance. Transitioning from languages like C# or Java, one might expect similar garbage collection behavior in Go, yet the approaches differ significantly. Developing proficiency in a language involves aligning with its design principles. That can only be achieved by being informed about internals.

0
Subscribe to my newsletter

Read articles from Siddhartha S directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

Siddhartha S
Siddhartha S

With over 18 years of experience in IT, I specialize in designing and building powerful, scalable solutions using a wide range of technologies like JavaScript, .NET, C#, React, Next.js, Golang, AWS, Networking, Databases, DevOps, Kubernetes, and Docker. My career has taken me through various industries, including Manufacturing and Media, but for the last 10 years, I’ve focused on delivering cutting-edge solutions in the Finance sector. As an application architect, I combine cloud expertise with a deep understanding of systems to create solutions that are not only built for today but prepared for tomorrow. My diverse technical background allows me to connect development and infrastructure seamlessly, ensuring businesses can innovate and scale effectively. I’m passionate about creating architectures that are secure, resilient, and efficient—solutions that help businesses turn ideas into reality while staying future-ready.