Привет, Хабр!

В Golang (да в принципе во всех ЯП) управление памятью и эффективное использование ресурсов — основа создания высокопроизводительных приложений. Одним из важных инструментов, который помогает справляться с этой задачей, является сборщик мусора (на англ garbage collection). Встроенный сборщик мусора Go выполняет свою работу довольно хорошо, но иногда требуется более тонкая настройка, чтобы соответствовать специальным требованиям потребностям конкретного приложения.

Здесь нам и помогут кастомные сборщики мусора.

Принципы работы сборщика мусора в Go

Для понимания того, как создавать кастомные сборщики мусора важно в общих чертах понимать то, как работает сам дефолтный сборщик.

mark-and-sweep, concurrent garbage collection

Основной метод сборки мусора в Go основан на алгоритме mark‑and‑sweep. Этот алгоритм состоит из двух основных этапов:

  1. Mark (Пометка): На этом этапе сборщик мусора проходит по всем объектам в памяти и помечает те, которые все еще используются. Процесс начинается с корневых объектов, таких как глобальные переменные и объекты на стеке, и рекурсивно помечает все объекты, на которые есть ссылки.

  2. Sweep (Очистка): После завершения этапа пометки сборщик мусора проходит по всей памяти и освобождает те объекты, которые не были помечены на предыдущем этапе, тем самым очищая неиспользуемую память.

Go использует конкурентный сборщик мусора, который выполняет свою работу параллельно с основным кодом программы. То есть большинство операций по сборке мусора выполняются без остановки выполнения программы, что снижает задержки и увеличивает производительность. Важно сказать, что даже в конкурентном режиме сборщик мусора иногда должен остановить выполнение всех остальных горутин для выполнения критически важных операций. Этот процесс называется «Stop The World».

«Stop The World» или сокращенно STW — это состояние, когда сборщик мусора временно останавливает выполнение всех горутин для безопасного выполнения своих задач. В Go это происходит в двух точках:

  1. Перед началом фазы пометки (mark phase): На этом этапе сборщик мусора подготавливает состояние системы и активирует барьер записи, который отслеживает изменения в памяти.

  2. После завершения фазы пометки: На этом этапе сборщик мусора завершает фазу пометки и деактивирует барьер записи.

Хотя Go старается минимизировать время, проведенное в состоянии STW, оно все же может оказывать влияние на производительность приложения.

GOGC и pacer

GOGC — это параметр, который управляет частотой сборок мусора в Go. Он определяет, на сколько процентов должна увеличиться куча перед запуском сборщика мусора. Значение по умолчанию GOGC — 100, что означает, что сборщик мусора будет запускаться, когда размер кучи увеличится на 100% с момента последней сборки. Изменяя значение GOGC, можно настроить баланс между использованием памяти и загрузкой CPU:

  • Увеличение GOGC (например, до 200) приводит к реже запускающимся сборкам мусора, что уменьшает нагрузку на CPU, но увеличивает использование памяти.

  • Уменьшение GOGC (например, до 50) приводит к более частым сборкам мусора, что снижает использование памяти, но увеличивает нагрузку на CPU.

Pacer — это механизм, который помогает сборщику мусора определять оптимальные моменты для запуска. Он рассчитывает «trigger ratio» — отношение, при котором сборщик мусора должен снова запуститься. Это отношение определяется, например, в зависимости от текущего использования памяти и нагрузки на систему. Pacer постоянно адаптирует это значение, чтобы достичь баланса между производительностью приложения и эффективностью использования памяти.

Если вас интересует более полное погружение в тему сборки мусора в Go, мы рекомендуем посмотреть наше видео на YouTube:

А теперь переходим к самой сути статьи — кастомным сборщикам.

Подходы к созданию кастомных GC

Изменение стандартного сборщика мусора

Изменение поведения встроенного сборщика мусора в Go может осуществляться через настройку параметров, таких как GOGC и использование функций из пакета runtime. Один из подходов — это управление параметрами сборщика мусора для улучшения его работы под конкретные требования приложения.

Параметр GOGC управляет частотой запуска сборщика мусора:

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)
}

Код изменяет параметр GOGC на 50, что заставляет сборщик мусора запускаться чаще. Мастхев для уменьшения использования памяти ценой большей нагрузки на процессор.

Есть некоторые функции из runtime которые позволяют принудительно управлять сборщиком мусора, например, выполнять его по расписанию или при достижении определенных условий:

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 {}
}

Код создает отдельную горутину, которая принудительно запускает сборщик мусора каждые 2 секунды. Хорошо в сценариях, где требуется регулярное освобождение памяти.

Для уменьшения пауз «Stop The World» можно управлять настройками времени задержки и использовать каналы для асинхронного освобождения памяти:

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("Сборка мусора завершена")
}

Здесь уже можно контролировать задержки и уменьшать паузы «Stop The World» при выполнении финализаторов.

Создание пользовательских аллокаторов

С аллокатарами памяти в Go можно управлять памятью более гибко, чем это возможно с использованием встроенного сборщика мусора. Создадим простой аллокатор, который работает параллельно со стандартным сборщиком мусора, управляя своей памятью для определенных типов данных.

Создадим специализированный аллокатор для управления памятью для структур типа Node.

Определяем структуры и глобальных переменных:

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]
}

Cоздадим функцию, которая выделяет память для нового Node. Если текущий сегмент памяти заполнен, она создает новый сегмент:

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
}

Создаем и используем аллокатор для создания объектов Node и управление их памятью:

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)
}

Добавим примитивное управление памятью, освобождая память, если она больше не нужна:

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

Используем функцию освобождения памяти:

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)
}

Реализация полностью кастомного сборщика мусора

Создание полностью кастомного сборщика мусора в Go — это уже задача посложней.

Основные этапы:

  1. Определение структуры данных для управления памятью

  2. Реализация аллокатора памяти

  3. Реализация сборщика мусора

  4. Интеграция сборщика мусора в приложение

Сначала создадим основные структуры для управления памятью и отслеживания объектов:

package main

import (
	"fmt"
	"sync"
)

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

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

Реализуем функции для выделения и освобождения памяти:

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)
}

Реализуем функции для пометки и очистки объектов:

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)
}

Интегрируем кастомный сборщик в приложение:

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)
}

Кастомные сборщики мусор бустят производительность приложений за счет более точного управления памятью и уменьшения пауз.

Надеемся, что статья вдохновит вас на эксперименты с управлением памятью в Go. Это и вправду очень интересно.

Если у вас есть вопросы или вы хотите поделиться своими опытом, оставляйте комментарии!

Больше практических навыков по инфраструктуре высоконагруженных систем вы можете получить на онлайн-курсе под руководством экспертов области.

Комментарии (3)


  1. AccountForHabr
    18.07.2024 07:25
    +3

    Мне кажется или сборщик мусора в статье какой то ненастоящий, в лучшем случае тянущий на то что в c++ называют arena?


    1. xakep666
      18.07.2024 07:25

      Более того, эти самые арены в Go есть, но экспериментальные и под флагом https://go.dev/src/arena/arena.go


    1. Leopotam
      18.07.2024 07:25

      Это про пул экземпляров одного типа, не более того. Арена подразумевает выделение большого непрерывного блока памяти, внутри которого уже самостоятельно выделять память под экземпляры разных типов, а освобождать не по одному экземпляру, а память арены целиком.