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 чаще всего:
- возврат указателя на локальную переменную,
- запись в
interface{}/any(передача через интерфейс почти всегда escape), - слишком большой объект (~64 KB+) для стека,
- замыкание захватывает переменную по ссылке,
- сохранение значения в slice/map/channel, который сам в heap,
- неизвестный во время компиляции размер (
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)
- Mark Setup, STW, ~10, 30 µs. Включается write barrier.
- Concurrent Mark, основная маркировка, идёт пока приложение работает. Использует ~25% CPU.
- Mark Termination, STW, ~10, 30 µs. Завершение, сбор финальных данных.
- 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=30 | CPU profile |
/debug/pprof/heap | heap |
/debug/pprof/allocs | все аллокации (с момента старта) |
/debug/pprof/goroutine | стэки всех горутин |
/debug/pprof/block | блокировки на синхронизации |
/debug/pprof/mutex | contention на mutex |
/debug/pprof/trace?seconds=5 | execution 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-коуча.
Оплата пока не подключена: премиум и курсы открываются промокодом.