ExpertТеория6 min

Сборщик мусора и внутренности runtime

Tri-color mark-and-sweep, GOGC, GOMEMLIMIT, escape analysis, аллокатор памяти и GC tuning

Go использует конкурентный сборщик мусора (GC) -- одно из ключевых инженерных достижений runtime. Понимание его работы позволяет писать приложения с предсказуемой латентностью и эффективным использованием памяти.

Обзор GC

Go GC -- это конкурентный, трёхцветный mark-and-sweep сборщик мусора:

  • Конкурентный: GC работает параллельно с вашей программой (мутатором)
  • Трёхцветный: объекты классифицируются на белые, серые и чёрные
  • Mark-and-sweep: фаза маркировки находит живые объекты, фаза очистки освобождает мёртвые
  • Non-generational: нет поколений (в отличие от Java/C#/.NET)
  • Non-compacting: объекты не перемещаются в памяти

Цели GC Go:

  1. Низкая латентность (паузы < 1ms) -- приоритет
  2. Высокая пропускная способность -- вторичная цель
  3. Простота -- минимум ручек настройки

Фазы GC

          ┌─────────────┐     ┌──────────────────┐     ┌──────────────────┐     ┌─────────┐
  ───────>│ Mark Setup   │────>│  Concurrent Mark  │────>│ Mark Termination │────>│  Sweep  │──>
  mutator │  (STW ~10μs) │     │  (concurrent)     │     │   (STW ~10μs)    │     │(concur.)│
          └─────────────┘     └──────────────────┘     └──────────────────┘     └─────────┘

1. Mark Setup (STW -- Stop The World)

  • Включает write barrier (барьер записи)
  • Все горутины останавливаются (STW пауза ~10-50 мкс)
  • Подготавливает корни маркировки (goroutine stacks, globals)

2. Concurrent Mark (конкурентная фаза)

  • GC worker горутины маркируют живые объекты
  • Приложение продолжает работать параллельно
  • Write barrier отслеживает изменения указателей

3. Mark Termination (STW)

  • Вторая STW пауза (~10-50 мкс)
  • Завершает маркировку, обрабатывает оставшуюся работу
  • Выключает write barrier
  • Вычисляет триггер для следующего GC цикла

4. Sweep (конкурентная очистка)

  • Освобождает белые (неотмеченные) объекты
  • Работает конкурентно с приложением
  • Память возвращается аллокатору, не ОС (обычно)

Трёхцветный алгоритм

Белый (white):  Потенциально мусор. Если после маркировки объект белый -- он мёртв.
Серый (grey):   Обнаружен как живой, но его ссылки ещё не проверены.
Чёрный (black): Живой, все его ссылки проверены.

Алгоритм:

  1. Все объекты начинают белыми
  2. Корни (стеки, глобалы) помечаются серыми
  3. Берётся серый объект, его ссылки помечаются серыми, сам становится чёрным
  4. Повторять пока серых не останется
  5. Все белые объекты -- мусор

Инвариант: чёрный объект никогда не указывает на белый

Write barrier обеспечивает этот инвариант: при записи указателя в чёрный объект, целевой объект помечается серым.

Write Barrier (барьер записи)

Write barrier -- это код, вставленный компилятором перед каждой записью указателя. Go использует hybrid write barrier (Go 1.8+):

Shade(*slot)    // Mark old referent grey
Shade(ptr)      // Mark new referent grey
*slot = ptr     // Perform the actual write

Гибридный барьер позволяет:

  • Не перескканировать стеки горутин (stack re-scanning eliminated)
  • Меньшие STW паузы

Write barrier работает только во время GC mark phase. Вне GC -- нулевой overhead.

GOGC -- управление частотой GC

GOGC (по умолчанию 100) определяет, когда запускать следующий цикл GC:

Next GC trigger = Live heap after last GC * (1 + GOGC/100)

Примеры:

  • GOGC=100 (default): GC при удвоении heap (live * 2)
  • GOGC=200: GC при утроении heap (live * 3) -- реже, больше памяти
  • GOGC=50: GC при 1.5x heap -- чаще, меньше памяти
  • GOGC=off: GC отключён полностью
# Set via environment variable
GOGC=200 ./myapp

# Set programmatically
import "runtime/debug"
debug.SetGCPercent(200)

Trade-off GOGC

GOGC Частота GC Использование памяти CPU на GC
50 Часто Низкое Высокое
100 Средне Среднее Среднее
200 Редко Высокое Низкое
off Никогда Растёт Нулевое

GOMEMLIMIT (Go 1.19+): мягкий лимит памяти

GOMEMLIMIT устанавливает soft memory limit для Go runtime:

# Set memory limit to 1 GB
GOMEMLIMIT=1GiB ./myapp

# Other formats
GOMEMLIMIT=512MiB ./myapp
GOMEMLIMIT=4294967296 ./myapp  # bytes
import "runtime/debug"

// Set programmatically
debug.SetMemoryLimit(1 << 30) // 1 GiB

Как работает GOMEMLIMIT

Когда heap приближается к лимиту:

  1. GC запускается чаще (игнорирует GOGC)
  2. Агрессивнее возвращает память ОС
  3. Если лимит превышен -- GC продолжает работать, но может замедлить приложение

Не является жёстким лимитом! Приложение не будет OOM-killed из-за GOMEMLIMIT. Это подсказка для GC.

Рекомендуемая конфигурация

# For container with 4GB memory limit:
# Leave ~10-20% for non-Go memory (goroutine stacks, cgo, OS cache)
GOMEMLIMIT=3200MiB

# Aggressive: disable GOGC, rely only on GOMEMLIMIT
GOGC=off GOMEMLIMIT=3200MiB ./myapp
# GC runs only when approaching memory limit -- maximum throughput

Паттерн Go 1.19+: GOGC=off + GOMEMLIMIT даёт максимальную пропускную способность в контейнерах с известным лимитом памяти.

runtime.GC() и runtime.ReadMemStats

import "runtime"

// Force garbage collection
runtime.GC()

// Read memory statistics
var ms runtime.MemStats
runtime.ReadMemStats(&ms)

fmt.Printf("Alloc:       %d MiB\n", ms.Alloc/1024/1024)       // Current heap allocation
fmt.Printf("TotalAlloc:  %d MiB\n", ms.TotalAlloc/1024/1024)   // Cumulative allocation
fmt.Printf("Sys:         %d MiB\n", ms.Sys/1024/1024)           // Memory obtained from OS
fmt.Printf("NumGC:       %d\n", ms.NumGC)                       // Number of GC cycles
fmt.Printf("GCPause:     %v\n", time.Duration(ms.PauseNs[(ms.NumGC+255)%256])) // Last GC pause
fmt.Printf("HeapObjects: %d\n", ms.HeapObjects)                 // Number of heap objects
fmt.Printf("HeapInuse:   %d MiB\n", ms.HeapInuse/1024/1024)     // Heap in use
fmt.Printf("HeapIdle:    %d MiB\n", ms.HeapIdle/1024/1024)      // Heap idle (returned to OS or cached)
fmt.Printf("StackInuse:  %d MiB\n", ms.StackInuse/1024/1024)    // Stack memory in use

GC tracing

# Detailed GC logging
GODEBUG=gctrace=1 ./myapp

# Output:
# gc 1 @0.012s 2%: 0.024+0.94+0.017 ms clock, 0.096+0.78/0.80/0+0.070 ms cpu, 4->4->1 MB, 4 MB goal, 0 MB stacks, 0 MB globals, 4 P
#   2% = percentage of CPU used by GC
#   4->4->1 MB = heap before -> heap after → live data
#   4 MB goal = target heap size for next GC

GC Pacing Algorithm

GC pacing определяет, когда запускать GC и сколько ресурсов выделить:

  1. Trigger: запуск GC при достижении целевого размера heap
  2. Assist: если аллокация обгоняет маркировку, горутины помогают GC (GC assist)
  3. Background: 25% CPU выделяется на GC worker горутины
  4. Feedback: pacing корректируется на основе предыдущих циклов
Heap         ┃                      ╱
Growth       ┃                   ╱──── Allocation rate
             ┃                ╱
             ┃       GC ──╱
             ┃    trigger╱
             ┃         ╱
             ┃      ╱
             ┃   ╱
Live data ───┃╱──────────────────────────
             ┗━━━━━━━━━━━━━━━━━━━━━━━━━━ Time

Управление стеком

Contiguous Stacks (с Go 1.4)

Каждая горутина начинает с маленького стека (2-8 КБ). При необходимости стек удваивается и содержимое копируется:

Initial:     [2KB stack]
After grow:  [4KB stack] ← data copied
After grow:  [8KB stack] ← data copied
After grow:  [16KB stack] ← data copied
...

Копирование стека возможно потому, что:

  1. Go обновляет все указатели на стек (runtime знает их расположение)
  2. Unsafe.Pointer на стековые данные может сломаться!

Сжатие стеков

GC также сжимает стеки, если горутина использует < 1/4 выделенного стека. Это предотвращает утечку стековой памяти.

Escape Analysis (анализ убегания)

Компилятор Go определяет, должна ли переменная располагаться на стеке или на heap:

# Show escape analysis decisions
go build -gcflags="-m" ./...
go build -gcflags="-m -m" ./...  # More detailed

Что "убегает" на heap

// 1. Pointer escapes via return
func newUser() *User {
    u := User{Name: "Alice"}  // Escapes to heap (returned pointer)
    return &u
}

// 2. Interface conversion (sometimes)
func printValue(v any) {
    fmt.Println(v)  // v may escape
}

func main() {
    x := 42
    printValue(x)   // x escapes to heap (boxed into interface)
}

// 3. Closure captures variable
func counter() func() int {
    count := 0          // Escapes (captured by closure)
    return func() int {
        count++
        return count
    }
}

// 4. Slice too large for stack
func bigSlice() {
    s := make([]byte, 64*1024)  // May escape (too big for stack)
    _ = s
}

// 5. Variable size (unknown at compile time)
func dynamicSlice(n int) {
    s := make([]byte, n)  // Escapes (size unknown at compile time)
    _ = s
}

Что остаётся на стеке

// 1. Local variables that don't escape
func add(a, b int) int {
    result := a + b  // Stays on stack
    return result    // Value, not pointer
}

// 2. Small fixed-size slices
func process() {
    buf := make([]byte, 256)  // May stay on stack (small, fixed size)
    _ = buf
}

// 3. Struct without escaping pointers
func createUser() User {  // Returns value, not pointer
    return User{Name: "Alice"}
}

Green Tea GC (Go 1.26)

Примечание: Green Tea GC -- это экспериментальная оптимизация, анонсированная для Go 1.26.

Ключевые улучшения:

  1. Reduced tail latency: более предсказуемые паузы GC
  2. Arena-like optimization: переиспользование памяти для короткоживущих объектов
  3. Better pacing: улучшенный алгоритм определения момента запуска GC
  4. Lower CPU overhead: менее агрессивное использование CPU для маркировки

Green Tea нацелен на:

  • Уменьшение p99 латентности GC пауз
  • Более эффективное управление памятью для request-scoped аллокаций
  • Сохранение обратной совместимости

Аллокатор памяти Go

Go использует иерархический аллокатор, вдохновлённый TCMalloc (Thread-Caching Malloc):

┌─────────────────────────────────────────────────┐
│                    mheap                         │
│  Global heap. Manages spans and large allocations│
├─────────────────────────────────────────────────┤
│              mcentral (per size class)           │
│  Central cache. Provides spans to mcache         │
├──────────┬──────────┬──────────┬────────────────┤
│ mcache P0│ mcache P1│ mcache P2│ mcache P3      │
│ Per-P    │ Per-P    │ Per-P    │ Per-P           │
│ local    │ local    │ local    │ local           │
│ cache    │ cache    │ cache    │ cache           │
└──────────┴──────────┴──────────┴────────────────┘

mcache (per-P local cache)

  • Один mcache на каждый логический процессор (P)
  • Аллокации < 32KB берутся из mcache без блокировок
  • Содержит spans для каждого size class (68 size classes)

mcentral (central cache)

  • Один mcentral на каждый size class
  • Когда mcache исчерпан, берёт span из mcentral
  • Требует блокировку (но по одному size class)

mheap (global heap)

  • Управляет виртуальной памятью
  • Получает память от ОС через mmap
  • Аллокации > 32KB идут напрямую через mheap

Spans (mspan)

Span -- это непрерывный блок страниц (8KB каждая), разделённый на объекты одного size class:

Size classes (примеры):
8 bytes, 16 bytes, 24 bytes, 32 bytes, 48 bytes, 64 bytes,
80 bytes, 96 bytes, 112 bytes, 128 bytes, ..., 32768 bytes

Span for 32-byte objects (1 page = 8KB):
┌──────┬──────┬──────┬──────┬──────┬──────┬──────┬──────┬─...
│ obj1 │ obj2 │ obj3 │ obj4 │ obj5 │ obj6 │ obj7 │ obj8 │
│ 32B  │ 32B  │ 32B  │ 32B  │ 32B  │ 32B  │ 32B  │ 32B  │
└──────┴──────┴──────┴──────┴──────┴──────┴──────┴──────┴─...

Настройка GC для разных нагрузок

Web-сервис (low latency)

# Default GOGC is usually fine
# Use GOMEMLIMIT to prevent OOM in containers
GOGC=100 GOMEMLIMIT=3GiB ./webserver

Batch processing (high throughput)

# Disable GOGC-based triggering, rely on memory limit
# Less GC = more throughput
GOGC=off GOMEMLIMIT=8GiB ./batchprocessor

Memory-constrained environment

# Lower GOGC = more frequent GC = less memory
GOGC=50 GOMEMLIMIT=512MiB ./microservice

Real-time-ish application

# GC tuning + manual control
GOGC=100 GOMEMLIMIT=2GiB ./realtime
// Pre-allocate and reuse with sync.Pool
var bufPool = sync.Pool{
    New: func() any {
        buf := make([]byte, 0, 4096)
        return &buf
    },
}

// Reduce allocations in hot path
func handleRequest(data []byte) []byte {
    bufPtr := bufPool.Get().(*[]byte)
    buf := (*bufPtr)[:0]
    defer func() {
        *bufPtr = buf
        bufPool.Put(bufPtr)
    }()

    // Use buf instead of allocating...
    buf = append(buf, data...)
    return process(buf)
}

Профилирование GC

import (
    "runtime"
    "runtime/debug"
)

// Print GC stats
func printGCStats() {
    var stats debug.GCStats
    debug.ReadGCStats(&stats)

    fmt.Printf("LastGC:     %s\n", stats.LastGC)
    fmt.Printf("NumGC:      %d\n", stats.NumGC)
    fmt.Printf("PauseTotal: %v\n", stats.PauseTotal)

    if len(stats.Pause) > 0 {
        fmt.Printf("LastPause:  %v\n", stats.Pause[0])
    }

    // Recent pauses
    for i, p := range stats.Pause {
        if i >= 5 {
            break
        }
        fmt.Printf("  Pause[%d]: %v\n", i, p)
    }
}

// Monitor GC in production
func monitorGC(ctx context.Context) {
    var lastNumGC uint32

    ticker := time.NewTicker(10 * time.Second)
    defer ticker.Stop()

    for {
        select {
        case <-ticker.C:
            var ms runtime.MemStats
            runtime.ReadMemStats(&ms)

            if ms.NumGC > lastNumGC {
                fmt.Printf("GC cycles: %d, Heap: %d MiB, Pause: %v\n",
                    ms.NumGC-lastNumGC,
                    ms.HeapAlloc/1024/1024,
                    time.Duration(ms.PauseNs[(ms.NumGC+255)%256]),
                )
                lastNumGC = ms.NumGC
            }

        case <-ctx.Done():
            return
        }
    }
}
# Enable GC trace
GODEBUG=gctrace=1 ./myapp

# Memory allocator trace
GODEBUG=allocfreetrace=1 ./myapp

# Schedule trace (shows GC events)
GODEBUG=schedtrace=1000 ./myapp

Проверь себя

Какой тип GC использует Go?

Что делает GOGC=off в сочетании с GOMEMLIMIT?

Что такое escape analysis и как его проверить?

Почему mcache аллокатор не требует блокировок?