Как и зачем создавать кастомные сборщики мусора в Go
- пятница, 19 июля 2024 г. в 00:00:07
Привет, Хабр!
В Golang (да в принципе во всех ЯП) управление памятью и эффективное использование ресурсов — основа создания высокопроизводительных приложений. Одним из важных инструментов, который помогает справляться с этой задачей, является сборщик мусора (на англ garbage collection). Встроенный сборщик мусора Go выполняет свою работу довольно хорошо, но иногда требуется более тонкая настройка, чтобы соответствовать специальным требованиям потребностям конкретного приложения.
Здесь нам и помогут кастомные сборщики мусора.
Для понимания того, как создавать кастомные сборщики мусора важно в общих чертах понимать то, как работает сам дефолтный сборщик.
Основной метод сборки мусора в Go основан на алгоритме mark‑and‑sweep. Этот алгоритм состоит из двух основных этапов:
Mark (Пометка): На этом этапе сборщик мусора проходит по всем объектам в памяти и помечает те, которые все еще используются. Процесс начинается с корневых объектов, таких как глобальные переменные и объекты на стеке, и рекурсивно помечает все объекты, на которые есть ссылки.
Sweep (Очистка): После завершения этапа пометки сборщик мусора проходит по всей памяти и освобождает те объекты, которые не были помечены на предыдущем этапе, тем самым очищая неиспользуемую память.
Go использует конкурентный сборщик мусора, который выполняет свою работу параллельно с основным кодом программы. То есть большинство операций по сборке мусора выполняются без остановки выполнения программы, что снижает задержки и увеличивает производительность. Важно сказать, что даже в конкурентном режиме сборщик мусора иногда должен остановить выполнение всех остальных горутин для выполнения критически важных операций. Этот процесс называется «Stop The World».
«Stop The World» или сокращенно STW — это состояние, когда сборщик мусора временно останавливает выполнение всех горутин для безопасного выполнения своих задач. В Go это происходит в двух точках:
Перед началом фазы пометки (mark phase): На этом этапе сборщик мусора подготавливает состояние системы и активирует барьер записи, который отслеживает изменения в памяти.
После завершения фазы пометки: На этом этапе сборщик мусора завершает фазу пометки и деактивирует барьер записи.
Хотя Go старается минимизировать время, проведенное в состоянии STW, оно все же может оказывать влияние на производительность приложения.
GOGC — это параметр, который управляет частотой сборок мусора в Go. Он определяет, на сколько процентов должна увеличиться куча перед запуском сборщика мусора. Значение по умолчанию GOGC — 100, что означает, что сборщик мусора будет запускаться, когда размер кучи увеличится на 100% с момента последней сборки. Изменяя значение GOGC, можно настроить баланс между использованием памяти и загрузкой CPU:
Увеличение GOGC (например, до 200) приводит к реже запускающимся сборкам мусора, что уменьшает нагрузку на CPU, но увеличивает использование памяти.
Уменьшение GOGC (например, до 50) приводит к более частым сборкам мусора, что снижает использование памяти, но увеличивает нагрузку на CPU.
Pacer — это механизм, который помогает сборщику мусора определять оптимальные моменты для запуска. Он рассчитывает «trigger ratio» — отношение, при котором сборщик мусора должен снова запуститься. Это отношение определяется, например, в зависимости от текущего использования памяти и нагрузки на систему. Pacer постоянно адаптирует это значение, чтобы достичь баланса между производительностью приложения и эффективностью использования памяти.
Если вас интересует более полное погружение в тему сборки мусора в Go, мы рекомендуем посмотреть наше видео на YouTube:
А теперь переходим к самой сути статьи — кастомным сборщикам.
Изменение поведения встроенного сборщика мусора в 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 — это уже задача посложней.
Основные этапы:
Определение структуры данных для управления памятью
Реализация аллокатора памяти
Реализация сборщика мусора
Интеграция сборщика мусора в приложение
Сначала создадим основные структуры для управления памятью и отслеживания объектов:
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. Это и вправду очень интересно.
Если у вас есть вопросы или вы хотите поделиться своими опытом, оставляйте комментарии!
Больше практических навыков по инфраструктуре высоконагруженных систем вы можете получить на онлайн-курсе под руководством экспертов области.