День 4: Garbage Collector, профилирование и оптимизации

🎯 Темы дня

  1. Как работает GC в Go (tri-color mark-and-sweep)
  2. GOGC и GOMEMLIMIT — настройка GC
  3. Профилирование памяти: pprof, heap dumps
  4. Практические оптимизации: sync.Pool, pre-allocation, zero-copy

📖 1. Garbage Collector — как работает

Алгоритм: Tri-color Mark-and-Sweep

Go использует concurrent, non-generational, tri-color mark-and-sweep GC.

Цвета объектов:

  • Белый — не проверен, потенциальный мусор
  • Серый — обнаружен, но потомки не проверены
  • Чёрный — проверен, потомки тоже проверены (живой)

Алгоритм:

  1. Mark phase (concurrent):
    • Найти все корни (stack, globals, финализаторы).
    • Пометить их как серые.
    • Для каждого серого объекта: пометить как чёрный, все его потомки — как серые.
    • Повторять, пока нет серых объектов.
  2. Sweep phase (concurrent):
    • Все белые объекты — мусор, освободить память.

Write Barrier

Чтобы GC работал concurrently с мутациями, используется write barrier — специальная инструкция, которая уведомляет GC об изменении указателей.

Стоимость: Небольшая overhead на каждую запись указателя.

Когда запускается GC

GC запускается, когда:

  1. Размер heap вырос в GOGC раз с последней сборки (по умолчанию 100%, т.е. удвоение).
  2. Достигнут GOMEMLIMIT (если задан).
  3. Принудительно через runtime.GC().

Цель GC

Главная цель Go GC — низкая latency, а не высокий throughput.

  • Цель: пауза < 500 микросекунд (обычно < 100 мкс).
  • Жертва: может потреблять больше памяти, чем нужно.
Фазы цикла GC и tri-color marking

Цикл целиком: два коротких STW-окна — Sweep Termination и Mark Termination — обрамляют конкурентные фазы Mark и Sweep. Пока программа продолжает работать, write barrier помогает GC раскрашивать объекты, поэтому «пауза» — это доли миллисекунды, а не весь цикл.


📖 2. GOGC и GOMEMLIMIT

GOGC

Определяет, насколько может вырасти heap перед запуском GC.

bash
GOGC=100  # по умолчанию: GC запускается, когда heap удвоился
GOGC=200  # GC запускается, когда heap вырос в 3 раза
GOGC=50   # GC запускается чаще, heap растёт медленнее

Когда менять:

  • GOGC=200 — если много памяти и хочешь реже запускать GC.
  • GOGC=50 — если мало памяти и хочешь экономить.

GOMEMLIMIT (Go 1.19+)

Жёсткий лимит памяти. GC будет запускаться чаще, чтобы не превысить лимит.

bash
GOMEMLIMIT=1GiB  # не превышать 1 GB

Важно: Это мягкий лимит. Go может временно превысить его, но будет стараться вернуться.

Когда использовать:

  • В контейнерах с ограниченной памятью (Kubernetes).
  • Чтобы избежать OOM killer.

📖 3. Профилирование памяти

pprof — основной инструмент

Подключить pprof в код:

go
import _ "net/http/pprof"

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

Снять heap profile:

bash
go tool pprof http://localhost:6060/debug/pprof/heap

Команды в pprof:

(pprof) top 10          # топ-10 функций по аллокациям
(pprof) list <func>     # показать аллокации в функции
(pprof) web             # графический вид (нужен graphviz)
(pprof) traces          # трассировка аллокаций

Поиск утечек памяти

Признаки утечки:

  • Heap растёт монотонно.
  • GC не освобождает память.
  • In-use space растёт.

Как найти:

  1. Снять два heap profile с интервалом:
    bash
    curl -o heap1.out http://localhost:6060/debug/pprof/heap
    sleep 30
    curl -o heap2.out http://localhost:6060/debug/pprof/heap
  2. Сравнить:
    bash
    go tool pprof --base heap1.out heap2.out
  3. Посмотреть, что аллоцируется и не освобождается.

MemStats

go
import "runtime"

var m runtime.MemStats
runtime.ReadMemStats(&m)

fmt.Printf("Alloc = %v MB\n", m.Alloc/1024/1024)
fmt.Printf("TotalAlloc = %v MB\n", m.TotalAlloc/1024/1024)
fmt.Printf("Sys = %v MB\n", m.Sys/1024/1024)
fmt.Printf("NumGC = %v\n", m.NumGC)

📖 4. Практические оптимизации

sync.Pool — переиспользование объектов

go
var bufPool = sync.Pool{
    New: func() interface{} {
        return new(bytes.Buffer)
    },
}

func process() {
    buf := bufPool.Get().(*bytes.Buffer)
    defer bufPool.Put(buf)
    
    buf.Reset()
    // используем buf
}

Зачем: Избежать аллокаций в hot path, снизить нагрузку на GC.

Когда использовать:

  • Частые аллокации однотипных объектов (buffers, temporary structs).
  • В HTTP handlers, где много запросов.

Когда НЕ использовать:

  • Если объекты живут долго.
  • Если аллокации редкие.

Pre-allocation слайсов

go
// ❌ Плохо: много аллокаций при append
s := make([]int, 0)
for i := 0; i < 1000; i++ {
    s = append(s, i)
}

// ✅ Хорошо: одна аллокация
s := make([]int, 0, 1000)
for i := 0; i < 1000; i++ {
    s = append(s, i)
}

strings.Builder для конкатенации

go
// ❌ Плохо: O(N²)
s := ""
for i := 0; i < 10000; i++ {
    s += "a"
}

// ✅ Хорошо: O(N)
var builder strings.Builder
builder.Grow(10000)
for i := 0; i < 10000; i++ {
    builder.WriteString("a")
}
s := builder.String()

Zero-copy конверсии (unsafe)

go
import "unsafe"

func stringToBytes(s string) []byte {
    return unsafe.Slice(unsafe.StringData(s), len(s))
}

func bytesToString(b []byte) string {
    return unsafe.String(unsafe.SliceData(b), len(b))
}

Опасно: Строка иммутабельна, слайс — нет. Если изменить слайс — UB.

Когда использовать:

  • High-performance код, где критичны аллокации.
  • Только если уверен, что слайс не будет изменён.

💻 Практика (1–2 часа)

Задание 1: Профилирование heap

Напиши код, который аллоцирует много памяти, и найди утечку через pprof:

go
var leaks []*bytes.Buffer

func leak() {
    buf := bytes.NewBuffer(make([]byte, 1024*1024)) // 1 MB
    leaks = append(leaks, buf)
}

func main() {
    go func() {
        log.Println(http.ListenAndServe("localhost:6060", nil))
    }()
    
    for i := 0; i < 100; i++ {
        leak()
        time.Sleep(100 * time.Millisecond)
    }
    
    time.Sleep(time.Hour)
}

Сними heap profile и найди, где аллоцируется память.

Задание 2: sync.Pool

Реализуй pool для bytes.Buffer и сравни производительность с аллокацией каждый раз:

go
var bufPool = sync.Pool{
    New: func() interface{} {
        return new(bytes.Buffer)
    },
}

func BenchmarkWithPool(b *testing.B) {
    for i := 0; i < b.N; i++ {
        buf := bufPool.Get().(*bytes.Buffer)
        buf.Reset()
        buf.WriteString("hello")
        bufPool.Put(buf)
    }
}

func BenchmarkWithoutPool(b *testing.B) {
    for i := 0; i < b.N; i++ {
        buf := new(bytes.Buffer)
        buf.WriteString("hello")
    }
}

Задание 3: Оптимизация аллокаций

Найди и исправь лишние аллокации в этом коде:

go
func processUsers(users []User) []string {
    var names []string
    for _, u := range users {
        names = append(names, u.Name)
    }
    return names
}

Исправление:

go
func processUsers(users []User) []string {
    names := make([]string, 0, len(users)) // pre-allocation
    for _, u := range users {
        names = append(names, u.Name)
    }
    return names
}

🗣️ Каверзные вопросы

  1. Как работает GC в Go? Ответ: Tri-color mark-and-sweep, concurrent, non-generational. Цель — низкая latency.

  2. Что такое GOGC? Ответ: Процент роста heap, при котором запускается GC. По умолчанию 100% (удвоение).

  3. Что такое GOMEMLIMIT? Ответ: Жёсткий лимит памяти. GC будет запускаться чаще, чтобы не превысить лимит.

  4. Как найти утечку памяти? Ответ: Снять два heap profile с интервалом, сравнить через pprof --base.

  5. Что такое sync.Pool? Ответ: Пул для переиспользования объектов, чтобы избежать аллокаций в hot path.

  6. Почему strings.Builder быстрее конкатенации? Ответ: Использует внутренний буфер с амортизированным ростом, избегая O(N²) копирований.

  7. Что опасного в zero-copy конверсии string ↔ []byte? Ответ: Строка иммутабельна, слайс — нет. Если изменить слайс — UB.


🧩 Проверь себя

1. Что опасного в zero-copy конверсии string ↔ []byte через unsafe?
2. Как найти утечку памяти через pprof?
3. Что означает GOGC=100 (значение по умолчанию)?
4. Зачем нужен GOMEMLIMIT (Go 1.19+)?
5. Главная цель сборщика мусора Go?
6. Какой GC используется в Go?
7. Когда уместен sync.Pool? — выберите все подходящие

🏁 Финальный чек-лист курса

Теория

  • Понимаю, как устроен стек горутины и как он растёт
  • Знаю, зачем нужен heap и как в нём аллоцируется память
  • Могу запустить escape analysis и объяснить его вывод
  • Понимаю alignment и padding в структурах
  • Умею оптимизировать порядок полей для уменьшения размера
  • Знаю, когда использовать value, а когда pointer
  • Понимаю method sets для value vs pointer receivers
  • Знаю, как работает GC (tri-color mark-and-sweep)
  • Понимаю GOGC и GOMEMLIMIT

Практика

  • Умею профилировать память через pprof
  • Могу найти утечку памяти
  • Использую sync.Pool для оптимизации
  • Применяю pre-allocation слайсов
  • Использую strings.Builder для конкатенации
  • Понимаю риски zero-copy конверсий

Топ-10 вопросов, которые точно спросят

  1. Чем stack отличается от heap?
  2. Что такое escape analysis?
  3. Почему размер структуры не равен сумме полей?
  4. Как оптимизировать размер структуры?
  5. Когда использовать pointer, а когда value?
  6. Что такое method sets?
  7. Как работает GC в Go?
  8. Что такое GOGC и GOMEMLIMIT?
  9. Как найти утечку памяти?
  10. Что такое sync.Pool и когда его использовать?

Удачи! 🚀