Раздел 22 · Go

Runtime, GC, escape analysis, pprof, generics

middle-senior~50 мин

без входаВойди, чтобы отмечать прогресс, вести заметки и решать задачи в редакторе. войти

Runtime, GC, escape analysis, pprof, generics

Третья тема собеседований, “что у Go под капотом и как его профилировать”. Без этого не возьмут на роли с performance-критериями: high-load API, обработчики потоков, инфраструктурные сервисы.

1. Stack vs Heap, escape analysis

В Go нет явного new против make против “размещения на стеке”. Это решает компилятор через escape analysis.

Stack

  • быстрая аллокация (просто двигаем указатель frame-а),
  • автоматическая очистка при выходе из функции,
  • per-горутина, начинается с 2 KB, растёт сегментами,
  • размер ограничен (но в Go практически не упирается).

Heap

  • управляется GC,
  • медленнее аллоцировать (нужно найти место в free-list),
  • объект живёт пока есть ссылка,
  • увеличивает работу для сборщика.

Escape analysis

Компилятор смотрит, “переживёт” ли переменная фрейм функции. Если да, она “escape-нет” в heap.

// stack
func a() int {
    x := 42
    return x // копия x возвращается, оригинал остаётся в стеке
}

// heap
func b() *int {
    x := 42
    return &x // указатель ссылается, x обязан жить дольше фрейма
}

Что “выгоняет” в heap чаще всего:

  1. возврат указателя на локальную переменную,
  2. запись в interface{} / any (передача через интерфейс почти всегда escape),
  3. слишком большой объект (~64 KB+) для стека,
  4. замыкание захватывает переменную по ссылке,
  5. сохранение значения в slice/map/channel, который сам в heap,
  6. неизвестный во время компиляции размер (make([]T, n) где n динамическое).

Как смотреть

go build -gcflags="-m" ./...
# или подробнее
go build -gcflags="-m -m" ./...

В выводе будут строки:

./main.go:10:6: can inline foo
./main.go:13:13: x escapes to heap
./main.go:13:13:    from &x (address-of) at ./main.go:13:12

Что часто спрашивают

  • Чем отличается new(T) от make([]T, n)? new возвращает указатель на zero, make инициализирует slice/map/channel.
  • Аллоцируется ли локальная структура в стеке всегда? Нет, если её адрес уходит наружу, она в heap.
  • Как проверить? -gcflags="-m".

2. Garbage Collector

Go-GC, non-generational, concurrent, tri-color, mark-and-sweep. Отдельные слова разберём по очереди:

  • non-generational, нет деления на young/old generation. Все объекты обрабатываются одинаково.
  • concurrent, основная фаза маркировки идёт параллельно с приложением. Без длинного STW.
  • tri-color, объекты имеют один из трёх цветов: white (не достижим, кандидат на удаление), gray (достижим, потомки не проверены), black (достижим, потомки проверены).
  • mark-and-sweep, две основные фазы: пометка и подметание.

Фазы и STW (stop-the-world)

  1. Mark Setup, STW, ~10, 30 µs. Включается write barrier.
  2. Concurrent Mark, основная маркировка, идёт пока приложение работает. Использует ~25% CPU.
  3. Mark Termination, STW, ~10, 30 µs. Завершение, сбор финальных данных.
  4. Concurrent Sweep, освобождение white-объектов, тоже параллельно.

Суммарный STW в современном Go (1.5+), обычно меньше 1 мс. Цель команды Go, “GC не должен быть проблемой для абсолютного большинства приложений”.

GOGC

Главная ручка:

GOGC=100  # default, GC запускается когда heap вырастает на 100%
GOGC=200  # GC реже, памяти больше, CPU меньше
GOGC=50   # GC чаще, памяти меньше, CPU больше
GOGC=off  # выключить GC (только для специфичных бенчей)

Формула target heap:

target = liveHeap + (liveHeap + GCRoots) * GOGC / 100

GOMEMLIMIT (Go 1.19+)

Soft-limit на total memory:

GOMEMLIMIT=512MiB

Когда heap приближается к лимиту, GC запускается чаще, даже если по GOGC ещё не пора. Это не жёсткий лимит, рантайм может его превысить, но старается удержаться. Ставится в Kubernetes-окружениях, где у пода есть memory request/limit.

gctrace, как смотреть GC в реальном времени

GODEBUG=gctrace=1 ./app

Будет выводить строки вида:

gc 12 @4.123s 0%: 0.012+1.2+0.034 ms clock, 0.10+0.50/2.4/0+0.27 ms cpu, 4->4->2 MB, 5 MB goal, 0 MB stacks, 0 MB globals, 8 P

Расшифровка ключевых полей:

  • gc 12, номер цикла,
  • @4.123s, время с старта,
  • 0%, доля CPU съеденная GC,
  • 4->4->2 MB, heap before, heap after, live size,
  • 5 MB goal, target до следующего GC.

3. pprof, профилирование

Включение в HTTP-сервисе

import _ "net/http/pprof" // регистрирует /debug/pprof/* в DefaultServeMux

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

После этого:

URLЧто
/debug/pprof/список профилей
/debug/pprof/profile?seconds=30CPU profile
/debug/pprof/heapheap
/debug/pprof/allocsвсе аллокации (с момента старта)
/debug/pprof/goroutineстэки всех горутин
/debug/pprof/blockблокировки на синхронизации
/debug/pprof/mutexcontention на mutex
/debug/pprof/trace?seconds=5execution trace

Скачать и посмотреть

go tool pprof -http=:8080 http://localhost:6060/debug/pprof/profile?seconds=30
go tool pprof -http=:8080 http://localhost:6060/debug/pprof/heap

Откроется браузер с flame graph и call graph. Альтернатива, pyroscope или Datadog continuous profiler.

В benchmark-ах

go test -bench=. -cpuprofile=cpu.out -memprofile=mem.out
go tool pprof cpu.out

В коде benchmark-а можно явно вызывать b.ReportAllocs(), чтобы увидеть аллокации.

Что искать в CPU-profile

  • top-функции по самому себе (flat) и с потомками (cum),
  • неожиданно высокий вес runtime.gcBgMarkWorker, GC съедает много, разбираться в аллокациях,
  • runtime.findRunnable, scheduler ищет работу, возможно слишком много мелких горутин,
  • regexp, encoding/json, синхронизация на mutex, classic hot spots.

Что искать в heap-profile

  • неожиданно большие slice/map в долгоживущих объектах,
  • *string или *time.Time где можно value,
  • interface{}-обёртки на горячем пути,
  • закрытия, держащие большой контекст.

премиум-урок

Продолжение в премиуме: 1 200 ₽ в месяц или 10 000 ₽ в год. Вместе с ним серверные проверки задач и школьные токены AI-коуча.

Оплата пока не подключена: премиум и курсы открываются промокодом.