DEV Community

Cover image for Memory Management in Go: 4 Effective Approaches
dami
dami

Posted on • Originally published at twilio.com

Memory Management in Go: 4 Effective Approaches

Go, also known as Golang, is known for its effective memory management system. While the language's garbage collector automates most tasks involving memory, understanding and implementing effective memory management techniques helps improve your Go applications' performance and resource usage.

So, in this tutorial, you’ll learn four effective techniques you can apply to manage memory even better in your Go application. Whether you are an experienced developer or just starting out, you will gain insightful knowledge that you can apply to make your Go code more memory-efficient.

Let’s get started!

Prerequisites

Before going any further, ensure you have the following:

  • Go installed on your system. You can download it from the official website.

  • A text editor or IDE of your choice to write and edit your Go code

  • A project directory for the tutorial's code

Go’s garbage collector

Go's memory management is primarily handled by its garbage collector (GC). The GC automatically allocates and deallocates memory, relieving developers from manual memory management tasks common in languages like C or C++.

Here’s an example to demonstrate how the GC works by tracking memory usage before and after garbage collection.

In your project directory, create a new file named main.go and paste the code below into it.

package main

import (

    "fmt"

    "runtime"

)

func main() {

    var mem runtime.MemStats

    runtime.ReadMemStats(&mem)

    fmt.Printf("Initial memory usage: %v KBn", mem.Alloc/1024)

    references := make([]*[1000000]int, 100)

    for i := 0; i < 100; i++ {

        references[i] = new([1000000]int)

    }

    runtime.ReadMemStats(&mem)

    fmt.Printf("Memory usage after allocation: %v KBn", mem.Alloc/1024)

    references = nil

    runtime.GC()

    runtime.ReadMemStats(&mem)

    fmt.Printf("Memory usage after garbage collection: %v KBn", mem.Alloc/1024)

}
Enter fullscreen mode Exit fullscreen mode

In the code above, the initial memory usage is printed, followed by the allocation of 100 large arrays, which significantly increases memory usage. The references slice is then set to nil to remove all references to these arrays, and a manual garbage collection is triggered. Finally, the memory usage is printed again to show the current memory usage.

Run the below command in your terminal to execute the code.

go run main.go
Enter fullscreen mode Exit fullscreen mode

You should see a result similar to the image below, indicating that the GC cleared the unused memory.

Image description

Memory management approaches in Go

In this section, you’ll learn about the approaches and techniques you can use to manage memory better in your Go application.

1. Design memory-efficient structs

In Go, structs are typed collections of fields. Each field in the struct can be of a different data type, making structs a flexible way of combining related data. Structs are commonly used to represent records or complex data structures.

While structs can be considered, in some respects, as being similar to classes in object-oriented languages such as Java and C++, Go does not support explicit inheritance as other languages do.

Here’s how you would define a struct in Go.

type StructName struct {

    field1 Type1

    field2 Type2

}
Enter fullscreen mode Exit fullscreen mode

When using structs in Go, it's important to consider how the fields are aligned in memory. You should order struct fields from largest to smallest to minimize the amount of padding between fields.

Padding refers to the extra space added between struct fields to align them properly in memory. This alignment is necessary for the CPU to access the fields efficiently, but it can lead to wasted memory if fields are not ordered carefully. By organizing fields from largest to smallest, you can reduce the amount of padding, making the memory layout more compact.

Replace the content of the main.go file with the below code block to demonstrate this.

package main

import (

    "fmt"

    "unsafe"

)

func main() {

    type Inefficient struct {

        a bool

        b int64

        c bool

        d int32

    }

    type Efficient struct {

        b int64

        d int32

        a bool

        c bool

    }

    fmt.Printf("Size of Inefficient: %d bytesn", unsafe.Sizeof(Inefficient{}))

    fmt.Printf("Size of Efficient: %d bytesn", unsafe.Sizeof(Efficient{}))

}
Enter fullscreen mode Exit fullscreen mode

The code above defines two structs: Inefficient and Efficient. The Inefficient struct arranges its fields in a way that increases memory usage due to extra padding between fields.

On the other hand, the Efficient struct organizes fields from largest to smallest, reducing padding and making the struct more memory-efficient. The difference in size is shown using the unsafe.Sizeof() function.

Run the command below in your terminal to execute the code.

go run main.go
Enter fullscreen mode Exit fullscreen mode

You will see the result displayed below.

Image description

As shown in the image above, arranging your structs from largest to smallest fields causes it to use memory more effectively.

2. Use pointers for large data structures

Pointers in Go are variables that store the memory address of another variable. They allow you to reference and manipulate the value of a variable directly in memory rather than working with a copy of the value.

Key facts about pointers:

  1. Declaration: A pointer is declared by placing an asterisk (*) before the type. For example, var p *int declares a pointer to an integer.

  2. Address Operator (&): To get the address of a variable, you use the address operator. For example, p = &x assigns the address of x to the pointer p.

  3. Dereferencing: To access or modify the value at the address stored in a pointer, you use the dereference operator (*). For instance, *p = 10 sets the value of x to 10 if p points to x.

Here’s a simple example to demonstrate pointers. Replace the contents of your main.go file with the below code block.

package main

import (

    "fmt"

)

func main() {

    number := 42

    fmt.Println("Initial value of number:", number)



    pointerToNumber := &number

    *pointerToNumber = 99



    fmt.Println("Value of number after pointer modification:", number)

    fmt.Printf("Memory address of number: %pn", pointerToNumber)

}
Enter fullscreen mode Exit fullscreen mode

In the code above, the variable number is initialized to 42. A pointer named pointerToNumber is created and assigned the memory address of number. The value of number is then updated to 99 by modifying the value stored directly by the pointerToNumber pointer.

Run the below command in your terminal to execute the code.

go run main.go
Enter fullscreen mode Exit fullscreen mode

You should see the result shown in the image below.

Image description

Using pointers is an effective way to save memory when working with large data structures. Instead of passing these data structures directly to a function, you can pass a pointer that holds their memory address. This allows the function to modify the original data without the overhead of creating a copy, which normally occurs when the value of an object is passed into a function.

However, you must be careful not to create references that can lead to memory leaks by setting the references to nil after the code is done. Replace the content of your main.go file with the code block below.

package main

import (

    "fmt"

    "runtime"

)

type LargeDataSet struct {

    values [8000000]int

}

func GenerateData() *LargeDataSet {

    data := &LargeDataSet{}

    for index := range data.values {

        data.values[index] = index % 1000

    }

    return data

}

func main() {

    fmt.Println("Memory usage before data generation:")

    DisplayMemoryStats()

    dataset := GenerateData()

    fmt.Println("nMemory usage after data generation:")

    DisplayMemoryStats()

    fmt.Printf("nFirst value in dataset: %dn", dataset.values[0])

    dataset = nil

    runtime.GC()

    fmt.Print("nMemory usage after removing dataset reference: ")

    DisplayMemoryStats()

}

func DisplayMemoryStats() {

    var memStats runtime.MemStats

    runtime.ReadMemStats(&memStats)

    allocatedMiB := memStats.Alloc / 1024 / 1024

    fmt.Printf("Allocated = %v MiB", allocatedMiB)

}
Enter fullscreen mode Exit fullscreen mode

In the example above, the GenerateData() function creates a large data structure and returns a pointer to it, avoiding the overhead of copying the large data. After executing the function, the pointer is set to nil, allowing the garbage collector to reclaim the memory.

Run the below command in your terminal to execute the code.

go run main.go
Enter fullscreen mode Exit fullscreen mode

You should see the result shown in the image below.

Image description

3. Manage variables scopes

The proper understanding and usage of variable scopes plays a vital role in memory management in Go. A variable's scope determines its visibility and lifetime within the program, impacting directly when the garbage collector can free up the memory associated with it.

Limit variables scopes

While writing your Go code, you should keep variable scopes as limited as possible so the garbage collector can free them as soon as possible.

To demonstrate this, replace the content of your main.go file with the code block below.

package main

import (

    "fmt"

)

func main() {

    message := "This is available throughout main"

    fmt.Println(message)

    {

        blockMessage := "This is only available in this block"

        fmt.Println(blockMessage)

        blockMessage = ""

    }

    fmt.Println(blockMessage)

}
Enter fullscreen mode Exit fullscreen mode

In the example above, the message variable is declared at the top of the main() function and is accessible throughout the entire function, including all nested blocks. Conversely, the blockMessage variable is defined within a nested block inside the main() function. Consequently, its scope is limited to that block, making it inaccessible outside of it.

Therefore, accessing the blockMessage variable outside its scope will result in a compilation error, as shown in the image below. This is because the Go compiler detects that the variable is not defined in that context.

Image description

Use global variables cautiously

You can also manage memory more effectively by using global variables cautiously since they persist throughout the program's entire lifecycle and can result in memory leaks.

Replace the content of your main.go file with the below code block.

package main

import (

    "fmt"

    "runtime"

)

var globalStorage *Storage

type Storage struct {

    Elements [800000]int32

}

func initializeStorage() {

    var tempStorage Storage

    for i := 0; i < len(tempStorage.Elements); i++ {

        tempStorage.Elements[i] = int32(i % 1000)

    }

    globalStorage = &tempStorage

}

func main() {

    fmt.Println("Memory usage before initializeStorage:")

    displayMemoryStats()

    initializeStorage()

    fmt.Println("nMemory usage after initializeStorage:")

    displayMemoryStats()

    fmt.Println("nMemory usage after local variable out of scope (globalStorage still set):")

    displayMemoryStats()

}

func displayMemoryStats() {

    var memStats runtime.MemStats

    runtime.ReadMemStats(&memStats)

    allocatedMiB := memStats.Alloc / 1024 / 1024

    fmt.Printf("Allocated = %v MiB", allocatedMiB)

}
Enter fullscreen mode Exit fullscreen mode

In the code block above, the initializeStorage() function creates a tempStorage variable and fills its Elements array with data. Then, the address of tempStorage is assigned to the global variable globalStorage.

Run the command below in your terminal to execute the code.

go run main.go
Enter fullscreen mode Exit fullscreen mode

You will see the below results displayed in your terminal.

Image description

As seen in the image above, although tempStorage goes out of scope when initializeStorage finishes, the global variable globalStorage continues to hold a reference to it because it was defined globally. This prevents the garbage collector from reclaiming the memory.

4. Pool resources

Go has a technique for managing memory resource-intensive scenarios. It allows you to reuse resources, such as objects and memory, instead of allocating and deallocating them repeatedly.

You should use sync.Pool when dealing with scenarios that require frequent reuse of short-lived objects to reduce allocation costs and garbage collection overhead, especially during concurrency operations.

To demonstrate this, replace the content of your main.go file with the code block below.

package main

import (

    "fmt"

    "sync"

)

var syncPool = sync.Pool{

    New: func() interface{} {

        return &syncStruct{}

    },

}

type syncStruct struct {

    Object string

}

func main() {

    data := syncPool.Get().(*syncStruct)

    data.Object = "Pooling resources!"

    fmt.Println(data.Object)

    syncPool.Put(data)

    data2 := syncPool.Get().(*syncStruct)

    fmt.Println(data2.Object)

}
Enter fullscreen mode Exit fullscreen mode

In the example above, sync.Pool is used to manage and reuse instances of the syncStruct type. When an object is requested from the pool using syncPool.Get(), if none are available, a new one is created. The object is then used, and after processing it is returned to the pool using syncPool.Put().

Run the command below in your terminal to run the code.

go run main.go
Enter fullscreen mode Exit fullscreen mode

You will see the results displayed below.

Image description

Profile and monitor memory usage

Go provides in-built methods for identifying bottlenecks and memory leaks in your Go application using its profiler: pprof.

To profile memory usage, you will need to import the runtime/pprof package and write memory profiles to files for later analysis. Running these profiles through pprof helps identify inefficient memory usage in your application.

Below is an example of its usage. Replace the contents of your main.go file with the code block below.

package main

import (

    "log"

    "os"

    "runtime"

    "runtime/pprof"

)

func main() {

    runtime.MemProfileRate = 2048

    profileFile, err := os.Create("mem.prof")

    if err != nil {

        log.Fatal("Failed to create memory profile file: ", err)

    }

    defer profileFile.Close()

    generateDataStructures()

    if err := pprof.WriteHeapProfile(profileFile); err != nil {

        log.Fatal("Unable to write memory profile: ", err)

    }

    log.Println("Memory profile saved to mem.prof")

}

func generateDataStructures() []map[int]string {

    structures := make([]map[int]string, 800000)

    for i := range structures {

        structures[i] = map[int]string{

            0: "A", 1: "B", 2: "C", 3: "D", 4: "E",

        }

    }

    return structures

}
Enter fullscreen mode Exit fullscreen mode

In the code above, Go’s runtime function runtime.MemProfileRate() is set to capture memory allocations in 2KB intervals. The program creates a profile file named mem.prof to store the profiling data. Finally, the profiling data is written to the mem.prof file for later analysis.

Run the code using the command below

go run main.go
Enter fullscreen mode Exit fullscreen mode

You should see a prompt that indicates the profile data has been saved, as shown in the image below.

Image description

Next, run the code below in your terminal to begin the analysis of the profile data using pprof.

go tool pprof mem.prof
Enter fullscreen mode Exit fullscreen mode

This will return the prompt, as seen in the image below.

![][image9]Explore the profile data with the top command, which displays the top memory-consuming functions.

Paste the below command in your terminal to see this command in action.

top
Enter fullscreen mode Exit fullscreen mode

Image description

The top memory-consuming functions are displayed in the image above.

It’s also possible to check the particular lines in each function that consumes the memory using thelist <function_name> command. Use the command below to see this in action.

list generateDataStructures
Enter fullscreen mode Exit fullscreen mode

Image description

The image above displays the memory allocation per line. It shows that line 32 consumes the most memory—64.15 MB.

That’s how to improve memory management in Go

In this article, you learned about Go's garbage collector and the best approaches for improved memory efficiency. You also learned how to use Go's built-in memory profiler to detect memory leaks in your code.

Effective memory management enhances application performance and reduces the risk of memory-related errors. By applying the techniques in this tutorial, you can confidently build memory-safe Go applications.

Cheers to more learning!

Top comments (0)