Master Golang Garbage Collector Techniques Like a PRO


GO, GOLANG Solutions

Explore the evolution, mechanics, and optimization of Go's garbage collector, along with practical examples and best practices for memory management

 

Garbage collection in Go is an automatic memory management feature that helps in the efficient allocation and deallocation of memory resources during program execution. It plays a crucial role in enhancing the performance and safety of Go applications by eliminating the need for manual memory management, which can be error-prone and complex. The garbage collector in Go works by tracking and freeing memory that is no longer in use by the program, thus preventing memory leaks and optimizing memory usage. This mechanism allows developers to focus more on application logic rather than managing memory, making Go a robust language for building high-performance and concurrent applications.

Master Golang Garbage Collector Techniques Like a PRO

 

1. Historical Context and Evolution of Go's Garbage Collector

The evolution of Go's garbage collector is a fascinating journey that reflects the language's commitment to performance and efficiency.

  • Initial Version (Go 1.0):
    • Used a fully serialized mark-and-sweep process.
    • Required stopping the entire process, leading to scalability issues​​.
  • Go 1.1 Enhancements:
    • Introduced parallel execution of mark and sweep phases on multicore hosts​​.
  • Go 1.3 Improvements:
    • Added support for accurate scanning of stack memory.
    • Identified and managed unsafe pointer conversions​​.
  • Major Overhaul in Go 1.5 (August 2015):
    • Implemented a concurrent, tri-color, mark-sweep collector.
    • Introduced write barrier for data integrity.
    • Significantly reduced "stop-the-world" pauses.
    • Focused on low latency and simplicity in GC design​​​​​​.
  • Continuous Refinements Post Go 1.5:
    • Ongoing improvements in minimizing CPU time and application throughput impact.
    • Enhanced GC pacing for better performance balancing.

 

2. Understanding Memory Management in Go

Memory management in Go is primarily handled through its automatic garbage collection mechanism.

 

2.1 Memory Allocation

  • Dynamic Allocation: Go allocates memory dynamically for objects and data structures.
  • New and Make Functions: The built-in functions new and make are typically used for allocation. new(T) allocates memory for a variable of type T, initializes it to the zero value of T, and returns a pointer. make(T, args) is used for slices, maps, and channels, which initializes the internal data structure and prepares the value for use.
type MyStruct struct {
    Field1 int
    Field2 string
}

// Using new
ptr := new(MyStruct) // Allocates memory for MyStruct, initializes it, and returns a pointer

// Using make
mySlice := make([]int, 10) // Allocates and initializes a slice of ints with length 10

 

2.2 Memory Deallocation

  • Automatic Garbage Collection: Go uses a garbage collector (GC) to automatically deallocate memory that is no longer reachable or needed.
  • No Explicit Freeing: Unlike languages like C, Go does not require the developer to explicitly free allocated memory. The garbage collector periodically runs and frees unused memory.

Consider a function in Go that creates a local slice:

func createSlice() {
    localSlice := make([]int, 1000)
    // use localSlice
}

When createSlice is called, memory for localSlice is allocated. After the function completes, localSlice is no longer reachable since it was a local variable. The garbage collector will eventually identify this unused memory and automatically free it.

 

2.3 Garbage Collector Characteristics

  • Concurrent Operation: The GC operates concurrently with the running program, minimizing pause times.
  • Tri-color Marking Algorithm: It uses a tri-color marking algorithm for efficiency.
  • Pacing Algorithm: To decide when to run, Go's GC uses a pacing algorithm that balances memory usage and GC overhead.
Master Golang Garbage Collector Techniques Like a PRO

 

3. The Tricolor Mark-and-Sweep Algorithm in Go's Garbage Collector

The tricolor mark-and-sweep algorithm is an efficient method used by Go's garbage collector (GC) for memory management. It categorizes objects in the heap into three colors: white, grey, and black, representing their states during the garbage collection cycle.

How It Works

  1. Initial State:
    • All heap objects start as white, indicating they are unprocessed.
  2. Marking Phase:
    • Roots (like global variables, stack items) are identified and marked grey. These are the entry points to the heap.
    • A grey object is reachable but not yet fully processed.
    • The GC scans grey objects; if a grey object references other white objects, those turn grey.
    • Once scanned, the object turns black, indicating it is in use and has been fully processed.
  3. Sweep Phase:
    • After marking, all black objects are considered in use, and white objects are deemed unreachable.
    • The GC then 'sweeps' the white objects, freeing the memory they occupied.

Here is a sample code how Go's garbage collector handles circular references:

package main

import (
	"fmt"
	"runtime"
)

type Object struct {
	reference *Object
}

func main() {
	// Initial setup
	obj1 := &Object{} // This object will be marked as grey initially
	obj2 := &Object{reference: obj1} // Another object referencing obj1

	// This is where the tri-color algorithm kicks in
	obj1.reference = obj2 // Creating a circular reference

	// Unsetting obj1 and obj2 to make them eligible for GC
	obj1 = nil
	obj2 = nil

	// Forcing a garbage collection cycle
	runtime.GC()

	fmt.Println("Garbage Collection executed")
}

Here's a breakdown of how the tricolor algorithm applies to above code:

  1. Initial State:
    • obj1 and obj2 are created. Initially, they would be considered white (unmarked) in the tricolor GC context.
  2. Circular Reference Setup:
    • We create a circular reference between obj1 and obj2. This means that each object is referencing the other, which could potentially lead to a memory leak in a garbage collector that doesn't handle circular references well.
  3. Unsetting References:
    • By setting obj1 and obj2 to nil, we've effectively removed any root references to these objects. In the tricolor algorithm, this would mean that there are no grey objects leading to these instances, making them candidates for garbage collection.
  4. Garbage Collection:
    • When runtime.GC() is called, Go's garbage collector will run. Despite the circular reference, the GC will identify that there are no active references (grey objects pointing) to obj1 and obj2 from any roots (like global or stack variables). Thus, they both become eligible for garbage collection.
  5. GC Execution:
    • The GC, using its mark-and-sweep algorithm, will mark these objects as collectible (remaining white, as they are unreachable) and will eventually sweep (clean up) this memory.

 

4. How Golang Garbage Collector can impact performance?

The garbage collector in Go can have an impact on the performance of a program, depending on how it is used and configured. Here are a few ways in which the garbage collector can impact performance:

  • GC Pause Time: Garbage collection in Go is done concurrently with the program's execution. However, there will be brief pauses in the program's execution while the garbage collector is running. These pauses can be noticeable if the program is running a performance-sensitive workload, such as a real-time application.
  • Memory Allocation: The garbage collector can cause additional memory allocation and deallocation, which can increase the program's memory usage and cause cache misses, leading to increased CPU usage.
  • CPU usage: The garbage collector uses some CPU time to identify and reclaim unreachable objects, this could cause performance issues if the program is running a CPU-bound workload.
  • GC trigger threshold: The garbage collector's trigger threshold is configurable via the runtime. The GOGC variable sets the initial garbage collection target percentage. If the trigger threshold is set too low, the garbage collector may run too frequently, leading to increased CPU usage and GC pause time. If the trigger threshold is set too high, the program may run out of memory before the garbage collector runs.
  • Memory Pressure: If your program is under memory pressure, it can cause a performance hit. Even though the Go garbage collector is efficient at releasing memory, if the program's memory usage is too high, it can cause the garbage collector to run frequently, and the program could run out of memory.
Master Golang Garbage Collector Techniques Like a PRO

 

5. Practical Examples using Garbage Collector in GO

Example-1: How memory allocation works in golang

Here is a simple golang example where the string object "hello, world" is allocated memory when the variable s is declared. As long as the variable s is in scope, the string object is considered reachable and will not be garbage collected. Once the variable s goes out of scope, the string object is no longer reachable and will be eligible for garbage collection.

package main

import "fmt"

func main() {
	// Allocate memory for a new string object
	s := "hello, world"

	// The variable s is still in scope, so the string object it references is considered reachable
	fmt.Println(s)

	// Once the variable s goes out of scope, the string object it references is no longer reachable
	// and will be eligible for garbage collection
}

 

Example-2: Manually trigger garbage collector using runtime.GC()

In this example we use runtime.GC() to manually trigger the garbage collector:

package main

import (
	"fmt"
	"runtime"
)

func main() {
	// Allocate some memory for the program to use
	s := make([]string, 0, 100000)
	for i := 0; i < 100000; i++ {
		s = append(s, "hello, world")
	}

	// Print the initial memory usage
	var m runtime.MemStats
	runtime.ReadMemStats(&m)
	fmt.Println("Initial HeapAlloc: ", m.HeapAlloc)

	// Trigger the garbage collector
	runtime.GC()

	// Print the memory usage after the garbage collector has run
	runtime.ReadMemStats(&m)
	fmt.Println("After GC HeapAlloc: ", m.HeapAlloc)

	// Release the memory
	s = nil
	// Trigger the garbage collector
	runtime.GC()
	// Print the memory usage after the garbage collector has run
	runtime.ReadMemStats(&m)
	fmt.Println("After release HeapAlloc: ", m.HeapAlloc)
}

This program uses the runtime package to manually trigger the garbage collector and then prints the heap allocation before and after the garbage collection.

Output:

# go run main.go 
Initial HeapAlloc:  1654512
After GC HeapAlloc:  37872
After release HeapAlloc:  37872

 

Example-3: Display Heap Stats before and after garbage collector is executed

Here is another example of how we can use the runtime.ReadMemStats() function to retrieve statistics about the program's memory usage and monitor the garbage collector:

package main

import (
	"fmt"
	"runtime"
)

func main() {
	var m runtime.MemStats

	// Print statistics before the slice is released
	runtime.ReadMemStats(&m)
	fmt.Println("HeapAlloc: ", m.HeapAlloc)
	fmt.Println("HeapIdle: ", m.HeapIdle)
	fmt.Println("HeapReleased: ", m.HeapReleased)
	fmt.Println("NumGC: ", m.NumGC)
	fmt.Println("-----------")

	// Allocate some memory for the program to use
	s := make([]string, 0, 100000)
	for i := 0; i < 100000; i++ {
		s = append(s, "hello, world")
	}

	// Print statistics after the slice is released
	runtime.ReadMemStats(&m)
	fmt.Println("HeapAlloc: ", m.HeapAlloc)
	fmt.Println("HeapIdle: ", m.HeapIdle)
	fmt.Println("HeapReleased: ", m.HeapReleased)
	fmt.Println("NumGC: ", m.NumGC)
	fmt.Println("-----------")
	// Release the memory
	s = nil

	// Manually trigger garbage collector
	runtime.GC()
	runtime.ReadMemStats(&m)
	fmt.Println("HeapAlloc: ", m.HeapAlloc)
	fmt.Println("HeapIdle: ", m.HeapIdle)
	fmt.Println("HeapReleased: ", m.HeapReleased)
	fmt.Println("NumGC: ", m.NumGC)
	fmt.Println("-----------")
}

In this example we create a large slice of string which can reserve some memory and then retrieve the memory statistics using the runtime.ReadMemStats() function. The statistics include the amount of memory that is currently allocated (HeapAlloc), the amount of memory that is currently unused and available (HeapIdle), the amount of memory that has been released to the operating system (HeapReleased) and the number of garbage collection runs (NumGC).

After this, we release the slice by setting it to nil and allow the garbage collector to reclaim the memory. Then we call runtime.GC() to manually trigger the garbage collector and retrieve the statistics again.

As we can see, after releasing the slice, the HeapAlloc and NumGC decreases, and the HeapReleased and HeapIdle increases.

# go run main.go 
HeapAlloc:  48048
HeapIdle:  3719168
HeapReleased:  3686400
NumGC:  0
-----------
HeapAlloc:  1654104
HeapIdle:  2072576
HeapReleased:  2072576
NumGC:  0
-----------
HeapAlloc:  37648
HeapIdle:  3710976
HeapReleased:  2039808
NumGC:  1
-----------

Also NOTE that the garbage collector runs are not guaranteed to happen at a specific time, so we may need to wait for a while or manually trigger the garbage collector using runtime.GC() to see the expected results.

We can also use the statistics to detect potential memory leaks and performance issues, also we can use HeapAlloc and HeapReleased to check if our program is releasing memory correctly.

 

Example-4: Using GODEBUG to debug golang garbage collector

GODEBUG is the controller of the variables and is used for debugging within the Go runtime. This variable contains a list of name=val key-value pairs, separated by commas. These named variables are used to tune the output of the debugging information the binary will return. Here is another piece of code which tries to free unused memory efficiently:

package main

import (
	"fmt"
	"runtime"
	"time"
)

func printStats(mem runtime.MemStats) {
	runtime.ReadMemStats(&mem)
	fmt.Println("mem.Alloc:", mem.Alloc)
	fmt.Println("mem.TotalAlloc:", mem.TotalAlloc)
	fmt.Println("mem.HeapAlloc:", mem.HeapAlloc)
	fmt.Println("mem.NumGC:", mem.NumGC)
	fmt.Println("-----")
}

func main() {
	var mem runtime.MemStats
	printStats(mem)

	for i := 0; i < 10; i++ {
		s := make([]byte, 100000000)
		if s == nil {
			fmt.Println("Operation failed!")
		}
	}
	printStats(mem)

	for i := 0; i < 10; i++ {
		s := make([]byte, 100000000)
		if s == nil {
			fmt.Println("Operation failed!")
		}
		time.Sleep(5 * time.Second)
	}
	printStats(mem)

}

We use for loop to obtain large amounts of memory in order to trigger the use of garbage collector.

Output:

# go run main.go 
mem.Alloc: 48256
mem.TotalAlloc: 48256
mem.HeapAlloc: 48256
mem.NumGC: 0
-----
mem.Alloc: 100045200
mem.TotalAlloc: 1000128496
mem.HeapAlloc: 100045200
mem.NumGC: 9
-----
^Csignal: interrupt

So, the output presents information about properties related to the memory used by the main.go program. If we want to get an even more detailed output, we can execute the same tool using GODEBUG, as shown here:

# GODEBUG=gctrace=1 go run main.go a
gc 1 @0.019s 1%: 0.014+2.4+0.001 ms clock, 0.014+0.33/0/0+0.001 ms cpu, 4->4->0 MB, 5 MB goal, 1 P
gc 2 @0.050s 3%: 0.027+5.1+0.002 ms clock, 0.027+0.37/1.0/0+0.002 ms cpu, 4->4->1 MB, 5 MB goal, 1 P
gc 3 @0.089s 2%: 0.067+3.3+0.002 ms clock, 0.067+0.66/0/0+0.002 ms cpu, 4->4->1 MB, 5 MB goal, 1 P
gc 4 @0.128s 2%: 0.032+2.6+0.003 ms clock, 0.032+0.82/0/0+0.003 ms cpu, 4->4->1 MB, 5 MB goal, 1 P
gc 5 @0.153s 2%: 0.046+5.1+0.002 ms clock, 0.046+0.81/0/0+0.002 ms cpu, 4->5->1 MB, 5 MB goal, 1 P
gc 6 @0.175s 3%: 0.030+11+0.002 ms clock, 0.030+1.4/0.16/0+0.002 ms cpu, 4->5->1 MB, 5 MB goal, 1 P
gc 7 @0.224s 2%: 0.027+2.4+0.003 ms clock, 0.027+0.63/0/0+0.003 ms cpu, 4->5->2 MB, 5 MB goal, 1 P
# command-line-arguments
gc 1 @0.004s 17%: 0.009+2.4+0.002 ms clock, 0.009+1.3/0/0+0.002 ms cpu, 4->6->5 MB, 5 MB goal, 1 P
gc 2 @0.036s 16%: 0.014+8.7+0.004 ms clock, 0.014+4.0/2.2/0+0.004 ms cpu, 9->9->8 MB, 11 MB goal, 1 P
mem.Alloc: 48128
mem.TotalAlloc: 48128
mem.HeapAlloc: 48128
mem.NumGC: 0
-----
gc 1 @0.007s 1%: 0.011+0.11+0.002 ms clock, 0.011+0.10/0/0+0.002 ms cpu, 95->95->0 MB, 96 MB goal, 1 P
gc 2 @0.054s 0%: 0.030+0.13+0.002 ms clock, 0.030+0.12/0/0+0.002 ms cpu, 95->95->0 MB, 96 MB goal, 1 P
gc 3 @0.106s 0%: 0.023+0.12+0.002 ms clock, 0.023+0.12/0/0+0.002 ms cpu, 95->95->0 MB, 96 MB goal, 1 P
gc 4 @0.141s 0%: 0.023+0.15+0.004 ms clock, 0.023+0.15/0/0+0.004 ms cpu, 95->95->0 MB, 96 MB goal, 1 P
gc 5 @0.185s 0%: 0.021+0.12+0.001 ms clock, 0.021+0.11/0/0+0.001 ms cpu, 95->95->0 MB, 96 MB goal, 1 P
gc 6 @0.221s 0%: 0.023+0.22+0.002 ms clock, 0.023+0.22/0/0+0.002 ms cpu, 95->95->0 MB, 96 MB goal, 1 P
gc 7 @0.269s 0%: 0.025+0.12+0.001 ms clock, 0.025+0.12/0/0+0.001 ms cpu, 95->95->0 MB, 96 MB goal, 1 P
gc 8 @0.311s 0%: 0.032+0.33+0.002 ms clock, 0.032+0.32/0/0+0.002 ms cpu, 95->95->0 MB, 96 MB goal, 1 P
gc 9 @0.350s 0%: 0.022+0.10+0.006 ms clock, 0.022+0.097/0/0+0.006 ms cpu, 95->95->0 MB, 96 MB goal, 1 P
gc 10 @0.390s 0%: 0.021+0.11+0.005 ms clock, 0.021+0.10/0/0+0.005 ms cpu, 95->95->0 MB, 96 MB goal, 1 P
mem.Alloc: 100045256
mem.TotalAlloc: 1000128368
mem.HeapAlloc: 100045256
mem.NumGC: 9
-----

Let's understand this output:

Output Description
gc 1 The garbage collection number. This number is incremented at each garbage collection.
@0.007s This garbage collection occurred 0.007s s after the program was started.
0% The percentage of time spent in GC since the program was started.
0.011+0.11+0.002 ms clock The wallclock/CPU times that occur for the phases of the GC. This can be expressed as Tgc = Tseq + Tmark + Tsweep.
Tseq: User Go routines time stop (stop the world sweep termination).
Tmark: The heap making time (concurrent mark and scan time).
Tsweep: Heap sweeping time (sweep the world mark termination).
95->95->0 MB GC start, GC end, and live heap sizes. This shows how well or how badly the garbage collector is doing
96 MB goal The goal heap size.
1 P 4 processors being used.

 

Example-5: Using pprof for Garbage Collection Analysis

pprof Overview: pprof is a profiling tool in Go that helps in analyzing the performance of a Go program. It can provide insights into memory usage, CPU profiling, and understanding garbage collection behavior.

package main

import (
	"log"
	"net/http"
	_ "net/http/pprof"
	"time"
)

func main() {
	go func() {
		log.Println(http.ListenAndServe("localhost:6060", nil))
	}()

	// Simulate some work
	for {
		work()
		time.Sleep(1 * time.Second)
	}
}

func work() {
	_ = make([]byte, 10*1024*1024) // Allocating memory
}

In this example:

  • We import net/http/pprof, which automatically registers pprof handlers to the default HTTP server.
  • The program starts an HTTP server at localhost:6060 where the pprof tool can be accessed.
  • The work function simulates memory allocation.
  • To view memory profiling, you can navigate to http://localhost:6060/debug/pprof/heap.

The runtime package in Go provides functions to retrieve garbage collection metrics. Here's an example:

package main

import (
	"fmt"
	"runtime"
	"time"
)

func main() {
	var stats runtime.MemStats
	for {
		runtime.ReadMemStats(&stats)
		fmt.Printf("Alloc = %v MiB", stats.Alloc / 1024 / 1024)
		fmt.Printf("\tTotalAlloc = %v MiB", stats.TotalAlloc / 1024 / 1024)
		fmt.Printf("\tSys = %v MiB", stats.Sys / 1024 / 1024)
		fmt.Printf("\tNumGC = %v\n", stats.NumGC)
		time.Sleep(5 * time.Second)
	}
}

In this code:

  • We use runtime.ReadMemStats to read memory statistics.
  • Alloc, TotalAlloc, Sys, and NumGC give us information about current memory allocation, total memory allocation, system memory, and the number of garbage collections that have occurred, respectively.

 

6. Summary

Go supports Garbage Collection (GC) so we do not have to deal with memory allocation and deallocation. However, GC might slow down our programs a little. We learned how memory is allocated inside a go program and how we can read statistics of different type of memory used, released with runtime package. We learned how we can use the runtime.ReadMemStats() function to monitor the program's memory usage and the garbage collector's performance.

Here are links which can help you learn and understand more about Golang garbage collector:

A Guide to the Go Garbage Collector
Getting to Go: The Journey of Go's Garbage Collector

 

Views: 196
Deepak Prasad

Deepak Prasad

He is the founder of GoLinuxCloud and brings over a decade of expertise in Linux, Python, Go, Laravel, DevOps, Kubernetes, Git, Shell scripting, OpenShift, AWS, Networking, and Security. With extensive experience, he excels in various domains, from development to DevOps, Networking, and Security, ensuring robust and efficient solutions for diverse projects. You can connect with him on his LinkedIn profile.

Can't find what you're searching for? Let us assist you.

Enter your query below, and we'll provide instant results tailored to your needs.

If my articles on GoLinuxCloud has helped you, kindly consider buying me a coffee as a token of appreciation.

Buy GoLinuxCloud a Coffee

For any other feedbacks or questions you can send mail to admin@golinuxcloud.com

Thank You for your support!!

Leave a Comment