How and Why to Create Custom Garbage Collectors in Go

How Garbage Collector Works in Go

To understand how to create custom garbage collectors, it is important to have a basic understanding of how the default garbage collector itself works.

mark-and-sweep, concurrent garbage collection

Go's basic garbage collection method is based on using the mark-and-sweep algorithmThis algorithm consists of two main stages:

  1. Mark: At this stage, the garbage collector goes through all the objects in memory and marks those that are still in use. The process starts with root objects, such as global variables and objects on the stack, and recursively marks all objects that are referenced.

  2. Sweep (Cleaning): After the marking phase is complete, the garbage collector goes through all the memory and frees those objects that were not marked in the previous phase, thereby cleaning up unused memory.

Go uses competitive garbage collector, which performs its work in parallel with the main program code. That is, most garbage collection operations are performed without stopping the program execution, which reduces latency and increases performance. It is important to say that even in concurrent mode, the garbage collector sometimes must stop the execution of all other goroutines to perform critical operations. This process is called “Stop The World“.

“Stop The World” or in short STW – is a state where the garbage collector temporarily stops all goroutines from executing to safely complete its tasks. In Go, this happens at two points:

  1. Before the mark phase begins: At this stage, the garbage collector prepares the system state and activates the write barrier, which monitors changes in memory.

  2. After completing the marking phase: At this point, the garbage collector completes the mark phase and deactivates the write barrier.

Although Go tries to minimize the time spent in STW state, it can still have an impact on the performance of your application.

GOGC and pacer

GOGC – is a parameter that controls the frequency of garbage collections in Go. It determines by what percentage the heap must grow before the garbage collector runs. The default value of GOGC is 100, which means that the garbage collector will run when the heap size has increased by 100% since the last collection. By changing the value of GOGC, you can adjust the balance between memory usage and CPU load:

  • Increasing GOGC (e.g. to 200) results in less frequent garbage collections, which reduces CPU load but increases memory usage.

  • Reducing GOGC (e.g. to 50) results in more frequent garbage collections, which reduces memory usage but increases CPU load.

Pacer – is a mechanism that helps the garbage collector determine the optimal moments to run. It calculates “trigger ratio” – the ratio at which the garbage collector should run again. This ratio is determined, for example, based on current memory usage and system load. Pacer constantly adapts this value to achieve a balance between application performance and memory efficiency.

If you're interested in a more complete dive into garbage collection in Go, we recommend watching our YouTube video:

Now let's move on to the very essence of the article – custom assemblers.

Approaches to creating custom GCs

Changing the default garbage collector

Changing the behavior of Go's built-in garbage collector can be done by setting options such as GOGC and using functions from the package runtimeOne approach is to control the parameters of the garbage collector to improve its performance for the specific requirements of the application.

The GOGC parameter controls how often the garbage collector runs:

package main

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

func main() {
    // установить значение GOGC в 50
    runtime.GOMAXPROCS(1) // ограничиваем количество процессов
    prevGOGC := debug.SetGCPercent(50)
    fmt.Printf("Предыдущее значение GOGC: %d\n", prevGOGC)

    // создание объектов для тестирования GC
    for i := 0; i < 1e6; i++ {
        _ = make([]byte, 1024)
    }

    // форсируем выполнение сборщика мусора
    runtime.GC()

    // возвращаем значение GOGC к умолчанию
    debug.SetGCPercent(prevGOGC)
}

The code changes the GOGC parameter to 50, which makes the garbage collector run more often. A must-have for reducing memory usage at the cost of increased CPU load.

There are some functions from runtime which allow you to force the garbage collector to run, for example, on a schedule or when certain conditions are met:

package main

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

func main() {
    // запуск сборщика мусора каждые 2 секунды
    go func() {
        for {
            time.Sleep(2 * time.Second)
            fmt.Println("Принудительный запуск сборщика мусора")
            runtime.GC()
        }
    }()

    // создание объектов для тестирования GC
    for i := 0; i < 1e6; i++ {
        _ = make([]byte, 1024)
    }

    // поддержание работы основного потока
    select {}
}

The code creates a separate goroutine that forces the garbage collector to run every 2 seconds. Good for scenarios where regular memory freeing is required.

To reduce pauses “Stop The World” you can control the delay time settings and use channels to asynchronously release memory:

package main

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

func main() {
    // настройка задержек для уменьшения пауз STW
    runtime.MemProfileRate = 0
    runtime.SetFinalizer(new(struct{}), func(x interface{}) {
        time.Sleep(50 * time.Millisecond) // искусственная задержка
    })

    // создание объектов для тестирования GC
    for i := 0; i < 1e6; i++ {
        _ = make([]byte, 1024)
    }

    fmt.Println("Основная работа завершена")
    runtime.GC()
    fmt.Println("Сборка мусора завершена")
}

Here you can already control delays and reduce “Stop The World” pauses when running finalizers.

Creating Custom Allocators

With memory allocators in Go, you can manage memory more flexibly than is possible with the built-in garbage collector. Let's create a simple allocator that runs in parallel with the standard garbage collector, managing its own memory for specific types of data.

Let's create a specialized allocator to manage memory for structures of the type Node.

We define structures and global variables:

package main

import (
    "fmt"
)

type Node struct {
    item        int
    left, right *Node
}

const nodesPerBucket = 1024

var (
    allNodes   [][]Node
    nodesLeft  int
    currNodeID int
)

func NodeFromID(id int) *Node {
    n := id - 1
    bucket := n / nodesPerBucket
    el := n % nodesPerBucket
    return &allNodes[bucket][el]
}

Let's create a function that allocates memory for a new one Node. If the current memory segment is full, it creates a new segment:

func allocNode(item int, left, right int) int {
    if nodesLeft == 0 {
        newNodes := make([]Node, nodesPerBucket)
        allNodes = append(allNodes, newNodes)
        nodesLeft = nodesPerBucket
    }
    nodesLeft--
    node := &allNodes[len(allNodes)-1][nodesPerBucket-nodesLeft-1]
    node.item = item
    node.left = NodeFromID(left)
    node.right = NodeFromID(right)
    currNodeID++
    return currNodeID
}

Create and use an allocator to create objects Node and managing their memory:

func main() {
    rootID := allocNode(1, 0, 0)
    leftID := allocNode(2, 0, 0)
    rightID := allocNode(3, 0, 0)

    rootNode := NodeFromID(rootID)
    rootNode.left = NodeFromID(leftID)
    rootNode.right = NodeFromID(rightID)

    fmt.Printf("Root: %+v\n", rootNode)
    fmt.Printf("Left: %+v\n", rootNode.left)
    fmt.Printf("Right: %+v\n", rootNode.right)
}

Let's add some primitive memory management, freeing memory when it is no longer needed:

func freeNode(id int) {
    n := id - 1
    bucket := n / nodesPerBucket
    el := n % nodesPerBucket
    allNodes[bucket][el] = Node{} // освобождаем память, заново инициализируя структуру
}

We use the memory release function:

func main() {
    rootID := allocNode(1, 0, 0)
    leftID := allocNode(2, 0, 0)
    rightID := allocNode(3, 0, 0)

    rootNode := NodeFromID(rootID)
    rootNode.left = NodeFromID(leftID)
    rootNode.right = NodeFromID(rightID)

    fmt.Printf("Before free - Root: %+v\n", rootNode)
    fmt.Printf("Before free - Left: %+v\n", rootNode.left)
    fmt.Printf("Before free - Right: %+v\n", rootNode.right)

    freeNode(leftID)
    freeNode(rightID)

    fmt.Printf("After free - Root: %+v\n", rootNode)
    fmt.Printf("After free - Left: %+v\n", rootNode.left)
    fmt.Printf("After free - Right: %+v\n", rootNode.right)
}

Implementation of a fully custom garbage collector

Creating a fully custom garbage collector in Go is a more difficult task.

Main stages:

  1. Defining a data structure for memory management

  2. Implementation of a memory allocator

  3. Implementation of Garbage Collector

  4. Integrating Garbage Collector into an Application

First, let's create the basic structures for memory management and object tracking:

package main

import (
	"fmt"
	"sync"
)

// Node представляет собой элемент в памяти
type Node struct {
	item int
	next *Node
}

// MemoryManager управляет памятью и сборкой мусора
type MemoryManager struct {
	mu       sync.Mutex
	nodes    []*Node
	freeList []*Node
}

We implement functions for allocating and freeing memory:

func NewMemoryManager() *MemoryManager {
	return &MemoryManager{
		nodes:    make([]*Node, 0),
		freeList: make([]*Node, 0),
	}
}

func (m *MemoryManager) Alloc(item int) *Node {
	m.mu.Lock()
	defer m.mu.Unlock()

	var node *Node
	if len(m.freeList) > 0 {
		// используем узел из списка свободных
		node = m.freeList[len(m.freeList)-1]
		m.freeList = m.freeList[:len(m.freeList)-1]
	} else {
		// создаем новый узел
		node = &Node{}
		m.nodes = append(m.nodes, node)
	}

	node.item = item
	node.next = nil
	return node
}

func (m *MemoryManager) Free(node *Node) {
	m.mu.Lock()
	defer m.mu.Unlock()

	// добавляем узел в список свободных
	node.item = 0
	node.next = nil
	m.freeList = append(m.freeList, node)
}

We implement functions for marking and clearing objects:

func (m *MemoryManager) Mark(root *Node) map[*Node]bool {
	visited := make(map[*Node]bool)
	stack := []*Node{root}

	for len(stack) > 0 {
		node := stack[len(stack)-1]
		stack = stack[:len(stack)-1]

		if node != nil && !visited[node] {
			visited[node] = true
			stack = append(stack, node.next)
		}
	}

	return visited
}

func (m *MemoryManager) Sweep(visited map[*Node]bool) {
	m.mu.Lock()
	defer m.mu.Unlock()

	for _, node := range m.nodes {
		if !visited[node] {
			m.Free(node)
		}
	}
}

func (m *MemoryManager) GC(root *Node) {
	visited := m.Mark(root)
	m.Sweep(visited)
}

We integrate a custom builder into the application:

func main() {
	mm := NewMemoryManager()

	// выделяем память для узлов
	root := mm.Alloc(1)
	node2 := mm.Alloc(2)
	root.next = node2
	node3 := mm.Alloc(3)
	node2.next = node3

	// запуск сборщика мусора
	fmt.Println("Before GC:")
	fmt.Println("Root:", root)
	fmt.Println("Node2:", node2)
	fmt.Println("Node3:", node3)

	mm.GC(root)

	// после GC, все еще используемые узлы должны остаться
	fmt.Println("After GC:")
	fmt.Println("Root:", root)
	fmt.Println("Node2:", node2)
	fmt.Println("Node3:", node3)
}

Custom garbage collectors boost application performance by managing memory more precisely and reducing pauses.

We hope this article inspires you to experiment with memory management in Go. It's really interesting.

If you have any questions or want to share your experience, please leave a comment!

We also remind you about the open lesson “Custom data types in PostgreSQL”, which will be held on July 23. There we will talk about the capabilities of PostgreSQL in terms of creating your own data types. We will analyze how you can create a custom type, how to work with it and several examples in detail “down to the screw”. You can sign up link.

Similar Posts

Leave a Reply

Your email address will not be published. Required fields are marked *