Модуль: Runtime и память · Уровень: Senior+

TL;DR#

pprof — это система статистического профилирования Go. Большинство профилей (CPU, heap, mutex, block) собираются через сэмплирование, поэтому стоят дёшево и пригодны для продакшена. Профиль можно собрать программно (runtime/pprof) или через HTTP-эндпоинты (net/http/pprof), а затем анализировать в go tool pprof (top/list/web/peek) или в continuous-profiling-системах (Pyroscope, Parca, Grafana Cloud). Ключ к правильному чтению — понимать flat vs cum и разницу между inuse/alloc для heap.

Теория#

Какие профили бывают#

Go runtime ведёт несколько предопределённых профилей. Их можно посмотреть в коде через runtime/pprof.Profiles() и по именам через pprof.Lookup(name).

ПрофильЧто измеряетТипКак собирается
cpu (profile)На чём тратится процессорное времяСэмплинг по таймеруSIGPROF ~100 Гц
heapЖивая и накопленная аллокация в кучеСэмплинг по объёмукаждые ~512 КБ аллокаций
allocsТо же, что heap, но дефолтный вид — alloc_spaceСэмплинг по объёмукак heap
goroutineСтеки всех горутин на момент снимкаПолный снимок (stop-the-world)по запросу
blockГде горутины блокируются (chan, mutex, select)Сэмплинг событий блокировкипо rate
mutexКонтеншн на sync.Mutex/RWMutexСэмплинг событий разблокировкипо fraction
threadcreateГде создавались OS-тредыСнимокпо запросу

Важно: heap и allocs — это один и тот же источник данных (профиль кучи), различаются лишь дефолтным sample_index, который выбирает инструмент при показе.

runtime/pprof: программный сбор#

Используется, когда нужен контролируемый сбор (CLI-утилита, бенчмарк, разовая диагностика) без открытия HTTP.

package main

import (
	"os"
	"runtime"
	"runtime/pprof"
)

func main() {
	// CPU-профиль: пишется потоково всё время между Start и Stop.
	f, _ := os.Create("cpu.prof")
	pprof.StartCPUProfile(f)
	defer pprof.StopCPUProfile()

	doWork()

	// Heap-профиль: это снимок текущего состояния, не поток.
	hf, _ := os.Create("heap.prof")
	runtime.GC() // важно: получить актуальную картину живых объектов
	pprof.WriteHeapProfile(hf)
	hf.Close()

	// Любой именованный профиль:
	gf, _ := os.Create("goroutine.prof")
	pprof.Lookup("goroutine").WriteTo(gf, 0) // debug=0 -> бинарный формат
	gf.Close()
}

WriteTo(w, debug): debug=0 — бинарный protobuf (для go tool pprof), debug=1 — человекочитаемый текст с агрегированными стеками, debug=2 (только goroutine) — полные стеки всех горутин, как при панике (очень полезно для дедлоков).

net/http/pprof: эндпоинты в проде#

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

func main() {
	// ОТДЕЛЬНЫЙ серверный mux на внутреннем порту, не публичный!
	go func() {
		http.ListenAndServe("localhost:6060", nil)
	}()
	// ... основной сервис
}

Что регистрирует анонимный импорт _ "net/http/pprof" (через init()):

  • /debug/pprof/ — индекс
  • /debug/pprof/profile?seconds=30 — CPU-профиль за N секунд
  • /debug/pprof/heap, /allocs, /goroutine, /block, /mutex, /threadcreate
  • /debug/pprof/trace?seconds=5 — execution trace (не pprof, а runtime/trace)
  • /debug/pprof/cmdline, /symbol

Подводный камень: если вы используете кастомный http.ServeMux, эндпоинты НЕ зарегистрируются автоматически (init пишет в DefaultServeMux). Тогда вешайте руками:

mux := http.NewServeMux()
mux.HandleFunc("/debug/pprof/", pprof.Index)
mux.HandleFunc("/debug/pprof/cmdline", pprof.Cmdline)
mux.HandleFunc("/debug/pprof/profile", pprof.Profile)
mux.HandleFunc("/debug/pprof/symbol", pprof.Symbol)
mux.HandleFunc("/debug/pprof/trace", pprof.Trace)

go tool pprof: анализ#

# Сбор и сразу анализ по URL (CPU, 30 секунд):
go tool pprof http://localhost:6060/debug/pprof/profile?seconds=30

# Heap по URL:
go tool pprof http://localhost:6060/debug/pprof/heap

# Из файла + бинарь (нужен для символизации в старых версиях):
go tool pprof ./mybin cpu.prof

# Веб-UI с флейм-графом (открывает браузер):
go tool pprof -http=:8080 cpu.prof

Основные команды интерактивного режима:

(pprof) top              # топ-10 функций по flat
(pprof) top20 -cum       # топ-20 по cumulative
(pprof) list regexp      # построчно показать исходник функции с весами
(pprof) peek funcName    # callers/callees функции (граф вызовов вокруг неё)
(pprof) web              # SVG граф вызовов в браузере
(pprof) traces           # показать примеры стеков-сэмплов
(pprof) tree             # текстовое дерево вызовов
(pprof) disasm regexp    # дизассемблер с весами (для микрооптимизаций)

flat vs cum — главное при чтении#

  • flat — время/память, потраченные непосредственно в теле самой функции (без вызываемых ею функций).
  • cum (cumulative) — функция плюс всё, что она вызвала вниз по стеку.

Эвристика:

  • Высокий flat = горячая точка прямо здесь (тут и оптимизируем).
  • Высокий cum при низком flat = функция сама дешёвая, но дерево под ней дорогое (это «маршрутизатор» — ищем глубже).
  • main/http.serve почти всегда имеют cum=100%, flat≈0 — это нормально.

Индексы heap-профиля#

Heap-профиль содержит 4 метрики (sample_index). Выбор кардинально меняет интерпретацию:

sample_indexЧто показывает
inuse_spaceБайты живых (не собранных GC) объектов — дефолт для heap, ищем утечки/высокий RSS
inuse_objectsКол-во живых объектов — ищем фрагментацию/много мелких объектов
alloc_spaceВсе аллоцированные байты за время жизни процесса — дефолт для allocs, ищем GC-давление
alloc_objectsКол-во аллокаций суммарно — ищем источник нагрузки на аллокатор
# Сколько живой памяти сейчас:
go tool pprof -sample_index=inuse_space http://localhost:6060/debug/pprof/heap

# Кто больше всех аллоцирует (давит на GC) за всё время:
go tool pprof -sample_index=alloc_space http://localhost:6060/debug/pprof/heap

Правило: высокий inuse — проблема живой памяти/утечка; высокий alloc при низком inuse — GC-давление от короткоживущих объектов (кандидаты на sync.Pool или escape-устранение).

Сравнение профилей (diff)#

Незаменимо для расследования регрессий и утечек во времени:

# base.prof снят раньше, cur.prof позже:
go tool pprof -http=:8080 -diff_base=base.prof cur.prof
# Покажет дельту — что выросло между двумя снимками.

Sampling rate — управление точностью/стоимостью#

// CPU: частота сэмплов SIGPROF в герцах. По умолчанию 100.
runtime.SetCPUProfileRate(250) // вызывать ДО StartCPUProfile, осторожно >500

// Heap: один сэмпл в среднем на каждые N байт аллокаций. По умолчанию 512*1024.
// Установить 1 = профилировать каждую аллокацию (дорого, но точно).
runtime.MemProfileRate = 1 // ставить в самом начале main/init, до аллокаций

// Block: фиксировать одно событие блокировки на каждые N наносекунд
// заблокированного времени. 0 = выключено (дефолт).
runtime.SetBlockProfileRate(10000) // 1 сэмпл / 10мкс блокировки

// Mutex: профилируется 1/N событий контеншена. 0 = выключено (дефолт).
runtime.SetMutexProfileFraction(5) // ~каждое 5-е событие

Нюанс: MemProfileRate нужно выставлять как можно раньше (изменение после старта аллокаций сделает картину неконсистентной). SetMutexProfileFraction(0) возвращает текущее значение, не меняя его — удобно прочитать.

Continuous profiling#

Разовый pprof ловит момент. Для продакшена нужны непрерывные профили во времени, чтобы коррелировать с инцидентами:

  • Grafana Pyroscope — агент тянет /debug/pprof/* или встраивается SDK pyroscope.Start(...), хранит time-series профили, есть diff между временными окнами.
  • Parca (eBPF + pprof) — может профилировать без инструментирования (system-wide), pull-модель по тем же эндпоинтам.
  • Grafana Cloud Profiles / Datadog Continuous Profiler — managed-варианты.

Все они опираются на стандартный pprof-формат, так что встраивание net/http/pprof уже половина дела.

Подводные камни / gotchas#

  • Heap — это снимок, CPU — поток. Перед WriteHeapProfile делайте runtime.GC(), иначе в inuse_space попадёт мусор, ещё не собранный GC.
  • Block и mutex выключены по умолчанию. Если профиль пустой — вы забыли SetBlockProfileRate/SetMutexProfileFraction.
  • Сэмплированные числа — оценки, не точные значения. pprof экстраполирует: один heap-сэмпл «весит» как ~512КБ. Для мелких/редких аллокаций цифры шумные; уменьшайте MemProfileRate для точности.
  • /debug/pprof нельзя выставлять в публичный интернет — это утечка стеков, имён функций и потенциальный DoS (?seconds=3600). Только internal-порт/mTLS/auth.
  • CPU-профиль на коротких/IO-bound сервисах почти пуст — SIGPROF срабатывает только когда поток реально на CPU. Для ожиданий используйте block-профиль или trace.
  • Кастомный mux игнорирует _ "net/http/pprof" — эндпоинты регистрируются только в DefaultServeMux.
  • -alloc_space vs утечка. Большой alloc_space != утечка. Утечка видна в растущем inuse_space между двумя снимками (diff_base).
  • Высокий SetMutexProfileFraction/SetCPUProfileRate сам добавляет оверхед и искажает профиль (наблюдатель влияет на наблюдаемое).
  • Inlining «съедает» функции. Заинлайненные мелкие функции могут не появиться отдельной строкой; смотрите list и помните про -gcflags=-l при отладке.

Вопросы на собеседовании#

В: В чём разница между flat и cum, и как по ним искать узкое место? О: flat — ресурс, потраченный в теле самой функции; cum — функция плюс весь её поддерев вызовов. Высокий flat — горячая точка, оптимизируем тут. Высокий cum при низком flat — функция-диспетчер, реальная работа глубже, идём по стеку вниз через peek/list. Корневые функции (main, http handler) всегда cum≈100%, и это не повод для паники.

В: Чем inuse_space отличается от alloc_space и когда какой смотреть? О: inuse_space — байты живых объектов на момент снимка (после последнего GC); по нему ищут утечки и высокий RSS. alloc_space — кумулятивно все аллоцированные байты за жизнь процесса; по нему ищут GC-давление от короткоживущих объектов. Утечка = растущий inuse между снимками; GC-давление = большой alloc при стабильном inuse.

В: Что именно делает анонимный импорт _ "net/http/pprof"? О: Его init() регистрирует хендлеры (Index, Profile, Cmdline, Symbol, Trace) в http.DefaultServeMux под /debug/pprof/. Если приложение слушает на DefaultServeMux — эндпоинты доступны сразу. Если используется кастомный mux — нужно регистрировать хендлеры из пакета net/http/pprof вручную.

В: Почему block- и mutex-профили часто оказываются пустыми? О: Они выключены по умолчанию (rate/fraction = 0) из-за оверхеда. Нужно явно вызвать runtime.SetBlockProfileRate(n) и runtime.SetMutexProfileFraction(n). Block считает по наносекундам ожидания, mutex — по доле событий контеншена.

В: Как безопасно собирать профили в продакшене? О: CPU/heap/block/mutex — сэмплированные и дешёвые, их можно держать включёнными. Эндпоинт /debug/pprof выносим на внутренний порт (localhost:6060) за auth/mTLS, никогда не в публичный интернет. Ограничиваем seconds. Для постоянного наблюдения — continuous profiling (Pyroscope/Parca) с умеренным rate. Heap-снимки безопасны, но не забываем, что goroutine с debug=2 делает stop-the-world.

В: Почему CPU-профиль веб-сервиса показал почти ноль, хотя latency высокая? О: CPU-профиль через SIGPROF фиксирует только время на CPU. Если сервис IO-bound (ждёт БД/сеть/мьютексы), время уходит на блокировки, а не на процессор. Нужно смотреть block-профиль, mutex-профиль или execution trace (go tool trace), которые видят ожидания и планировщик.

В: Что показывает peek и чем отличается от list? О: peek funcName показывает непосредственных вызывающих (callers) и вызываемых (callees) функции с их весами — локальный фрагмент графа вызовов; полезно понять, кто и сколько раз приводит к горячей функции. list funcName показывает исходный код функции построчно с flat/cum на каждой строке — для точной локализации горячей строки.

В: Что значит MemProfileRate = 1 и зачем менять дефолт? О: По умолчанию профилируется в среднем одна аллокация на 512 КБ. =1 означает профилировать каждую аллокацию — максимальная точность ценой большого оверхеда и памяти; полезно в бенчмарке/локальной диагностике редких аллокаций. В проде дефолт оставляют, иначе наблюдатель искажает поведение.

В: Как доказать наличие утечки памяти через pprof? О: Снять два heap-снимка с интервалом (base.prof, затем cur.prof), сравнить go tool pprof -diff_base=base.prof -sample_index=inuse_space cur.prof. Постоянно растущий inuse_space у конкретного стека под нагрузкой = утечка (или неограниченный кэш). Параллельно проверить goroutine-профиль на рост числа горутин (типичная причина — заблокированные горутины, держащие память).

На что копают на senior+#

  • Понимание, что все основные профили статистические, и умение объяснить математику экстраполяции сэмплов (heap rate, CPU Hz) и связанный bias.
  • Способность связать профиль с моделью памяти Go: escape analysis, разница stack/heap, почему alloc_objects коррелирует с GC-частотой, как sync.Pool снижает alloc_space.
  • Корреляция pprof + execution trace + GC-метрики (gctrace): когда одного pprof недостаточно и нужен trace для тайминга и планировщика.
  • Continuous profiling в проде: pull vs push, безопасность эндпоинтов, overhead-бюджет, diff по временным окнам для расследования регрессий, корреляция с дашбордами latency.
  • Тонкости символизации, инлайнинга и того, как -gcflags влияет на читаемость профиля; чтение disasm для микрооптимизаций горячего пути.