Non-ideality doesn't mean inefficiency: GC in Go
Hello, dear colleagues! Today I would like to talk about the "lungs" of Golang — its Garbage Collector (GC). While this topic is well-known and frequently discussed, since I've taken on the goal of gradually exploring the most important components of the language, I couldn't skip it.
Introduction
The Garbage Collector (GC) in Go is often called "non-ideal" due to its relative simplicity compared to more complex implementations in other programming languages, but this doesn't mean it's ineffective for real applications.
On the contrary, the language development team made a very deliberate and well-reasoned choice in favor of implementation simplicity and behavior predictability, consciously sacrificing some theoretically possible optimizations to create a more reliable and understandable memory management system.
How Does GC Work in Go?
Golang uses a concurrent, tri-color Mark-and-Sweep algorithm. Let's examine its working principle in more detail. As the name suggests, this algorithm has two phases:
- Mark Phase — GC marks all reachable objects using three colors: white, gray, and black:
- The first step is initial marking, during which there is a very short stop-the-world pause (STW) to identify all root objects (e.g., global variables, stack variables). These roots are marked gray.
- In the second step, GC concurrently traverses the object graph, marking reachable objects as gray and placing them in a queue for scanning. After scanning all child elements of an object, it becomes black. Objects that are unreachable remain white. This step is performed concurrently with the application code, minimizing disruptions.
- The final step of the phase is mark termination, which is also accompanied by a short STW to ensure marking is complete.
- Sweep Phase — removal of all white objects that remained after marking. This operation is performed concurrently with the application's execution.
Abstractions and Concepts
- White — objects that haven't been visited yet (potential garbage).
- Gray — objects that have been visited, but their child elements haven't been scanned yet.
- Black — objects that have been visited, and all their child elements have been scanned. They are considered alive.
- Write Barriers — small code fragments inserted by the Go compiler during pointer write operations. They help GC track changes in the object graph during concurrent marking, ensuring that no live objects are mistakenly marked as white and cleaned up.
Stop The World (STW) Pauses
Modern GC in Go has very short STW pauses — averaging <0.5ms for most programs. This is achieved through:
- Concurrent marking
- Incremental stack scanning
- Write barriers, allowing the program to continue working during garbage collection
Comparison with Other Languages
Java (JVM GC): JVM offers a wide range of GCs (G1, CMS, Parallel, ZGC, Shenandoah), which are often much more complex and configurable. Some of them also aim for low latency (ZGC, Shenandoah) but usually require more attention to configuration. Go GC is simpler by design and less configurable, with an emphasis on "working out of the box". Java GC can lead to longer STW pauses in certain scenarios, although modern implementations have significantly improved.
C# (.NET Core GC): .NET Core has a generational GC that divides objects into generations (Gen 0, Gen 1, Gen 2), collecting young objects more frequently. This is effective for short-lived objects. Go GC is not generational, it scans the entire heap, although its concurrent nature reduces the impact of this.
Python/Ruby (Reference Counting + Generational GC): These languages often use a combination of reference counting (for quick cleanup) and generational GCs (for handling cyclic references). Reference counting can be slow due to counter update overhead, and cyclic references require additional mechanisms. Go avoids these problems through its "mark-and-sweep" algorithm.
| Language | Typical STW Pause | Algorithm |
|---|---|---|
| Go | < 0.5ms | Concurrent Mark-Sweep |
| Java | 10–100ms | Various (G1, ZGC, Shenandoah) |
| Python | From 10ms | Reference counting + Mark-Sweep |
| .NET | 1–100ms | Generational Mark-Sweep |
Advantages and Disadvantages of Go GC
Advantages
- Low Latency — the main advantage! Very short STW ensures minimal impact on application responsiveness.
- Simplicity (for developers) — GC works "out of the box" and requires no significant configuration. Developers can focus on business logic rather than memory management details.
- Concurrency — most GC work is performed concurrently with application code, effectively utilizing available processor cores.
- No Generations — simplifies design and eliminates some complexities associated with generational GCs.
Disadvantages
- Lack of Generational Collection — PARADOX! This is an advantage in terms of simplicity, but the absence of a generational approach means that GC cannot optimize collection for short-lived objects as effectively as generational GCs. This can lead to higher GC load with many short-lived objects.
- Memory Usage — Go GC may consume more memory compared to some other GCs, as it aims to maintain low latency rather than minimize memory usage.
- Less Configurability — for most cases this is an advantage, but for very specific workloads where deep GC tuning is needed, Go offers fewer options.
Runtime GC Management
Although Go GC is mostly automatic, you can influence its behavior using environment variables and functions from the runtime/debug package.
Environment Variable GOGC
GOGC controls the threshold for starting the GC cycle. By default, GOGC=100, meaning GC starts when the size of the live heap grows by 100% (i.e., doubles) compared to the heap size after the previous GC cycle.
GOGC=off— completely disables GC. Not recommended for production, although there are cases where it might be necessary. For example, if a program runs and completes entirely (memory costs will increase significantly, but the absence of STW will affect performance).GOGC=50— GC starts when the live heap increases by 50%. More frequent GC runs can lead to lower memory usage but more GC cycles.GOGC=200— GC starts when the live heap increases by 200%. Less frequent GC runs can lead to temporarily higher memory usage but fewer GC cycles.
# running program with GOGC=50
GOGC=50 go run main.goPackage runtime/debug
The runtime/debug package provides functions for programmatic interaction with GC.
debug.SetGCPercent(percent int)
Sets the GOGC threshold in code and returns the previous value.
package main
import (
"fmt"
"runtime"
"runtime/debug"
"time"
)
// printMemStats prints main memory usage indicators
func printMemStats(stage string) {
var m runtime.MemStats
runtime.ReadMemStats(&m)
fmt.Printf("--- %s ---\n", stage)
fmt.Printf(" HeapAlloc: %d bytes (%.2f MB)\n", m.HeapAlloc, float64(m.HeapAlloc)/(1024*1024))
fmt.Printf(" HeapObjects: %d\n", m.HeapObjects)
fmt.Printf(" NumGC: %d\n", m.NumGC)
fmt.Println("--------------------")
}
func main() {
// Set GOGC to a very low value to see GC more frequently.
// This is for demonstration only, not for production.
oldGCPercent := debug.SetGCPercent(10)
fmt.Printf("Previous GC percent: %d\n", oldGCPercent)
printMemStats("Program start")
// Create a lot of "garbage" to provoke GC
fmt.Println("Creating short-lived objects (should trigger GC more often with GOGC=10):")
for i := 0; i < 5; i++ {
// Allocate temporary buffer that will become "garbage"
_ = make([]byte, 20*1024*1024) // 20 MB of temporary data
fmt.Printf("Iteration %d:\n", i+1)
printMemStats(fmt.Sprintf("After allocating %dMB (iteration %d)", (i+1)*20, i+1))
time.Sleep(100 * time.Millisecond) // Small pause to let GC work
}
// Try to collect garbage explicitly (usually not needed)
fmt.Println("Calling runtime.GC() explicitly:")
runtime.GC()
printMemStats("After explicit runtime.GC()")
// Return GC percent to the default value (or to the initial one)
debug.SetGCPercent(100)
fmt.Println("Restored GC percent to 100")
printMemStats("After restoring GC percent")
time.Sleep(500 * time.Millisecond) // Give system time for final operations
}runtime.GC()
Explicitly starts a GC cycle. Not recommended for general use, as GC in Go is quite efficient and manual triggering can lead to unnecessary pauses. Use it only in very specific scenarios, for example, after freeing large resources when you know garbage collection can significantly reduce memory usage.
package main
import (
"fmt"
"runtime"
"time"
)
func main() {
fmt.Println("Before GC:")
printMemStats()
buf := make([]byte, 1024*1024*100) // Allocate 100 MB
fmt.Println("After allocating 100 MB:")
printMemStats()
buf = nil // Drop the last reference so the buffer becomes collectable
runtime.GC()
fmt.Println("After manual GC:")
printMemStats()
time.Sleep(1 * time.Second) // Give GC some time
}
func printMemStats() {
var m runtime.MemStats
runtime.ReadMemStats(&m)
fmt.Printf(" HeapAlloc: %d bytes\n", m.HeapAlloc)
fmt.Printf(" NumGC: %d\n", m.NumGC)
}GC Usage Recommendations
- Avoid creating temporary objects in hot loops
- Use object pools for frequently created structures
- Monitor GC metrics in production
- Configure
GOGCbased on real workloads
Conclusions
Go's Garbage Collector is a central element of Go's performance, providing low latency and efficient memory management with minimal developer overhead. While it's not as flexible as some JVM GCs, its simplicity and "works out of the box" design make it ideal for most applications. Understanding its principles and the ability to easily tune it using GOGC or debug.SetGCPercent will allow you to create even more optimized and reliable Go programs.