Профилирование в Go: что показывает pprof и чего он не показывает по умолчанию
Собеседование по профилированию идёт лестницей: как найти, что тормозит, — как снять профиль — чем flat отличается от cum — как искать утечку памяти — почему профиль блокировок пустой — можно ли профилировать в продакшене. Прогон отвечает на каждый шаг: профиль CPU — это 100 сэмплов в секунду; функция, выделившая почти 600 МБ, в профиле удерживаемой памяти отсутствует; профили блокировок и мьютексов пусты при настоящей конкуренции, пока их не включить; а цена профиля CPU — в пределах шума.
Полное техническое изложение
TL;DR
Профилировщик Go — сэмплирующий: он не считает каждый вызов, а с некоторой
частотой смотрит, что сейчас выполняется. Это и есть ответ для собеседования.
Частота — деталь реализации: в runtime/pprof go1.24.7 профиль CPU снимает
100 сэмплов в секунду, и число зашито в исходнике. Замер: 203 сэмпла за
2,11 с работы одной горутины. Отсюда главное правило чтения:
профиль — выборка, и функция, работавшая 5 мс, в нём может не появиться вовсе.
Дальше — то, что отличает понимание от пересказа. В выводе go tool pprof
две колонки времени: flat — время в самой функции, cum — вместе с
вызванными. У функции-диспетчера flat 0, а cum 1800 мс — 86,96 % профиля.
У профиля кучи два разных вида значений: inuse_space — что удерживается
сейчас, и alloc_space — что выделено за всё время. Функция, выделившая
596,83 МБ и ничего не удержавшая, в inuse_space отсутствует: утечку ищут по
одному виду, давление на сборщик — по другому.
Два профиля выключены по умолчанию. Профили блокировок и мьютексов при
настоящей конкуренции дали 0 записей, пока их не включили вызовами
SetBlockProfileRate и SetMutexProfileFraction. Пустой профиль не значит
«блокировок нет». Профиль горутин показывает подозрительное скопление: сто
горутин, ждущих канала, в который никто не пишет, лежат под одним стеком;
утечку доказывает то, что этот стек растёт от снятия к снятию. Профиль CPU
обычно достаточно лёгок для ограниченного снятия в продакшене: на нашей
нагрузке надбавка не вышла за шум замера, но это не универсальное число. И
/debug/pprof — служебная поверхность: наружу его не открывают.
- что такое горутина и стек вызовов;
- что сборщик мусора освобождает память, которая больше не используется, — это урок о сборщике мусора;
- как запустить тесты и бенчмарки через
go test.
- как снять профиль: тесты,
net/http/pprof,runtime/pprof; - flat и cum,
inuse_spaceиalloc_space, частота сэмплов; - профили блокировок, мьютексов и горутин, цена профилирования.
Что здесь на самом деле спрашивают
Лестница почти всегда такая:
- «Как найти, что тормозит?» — разминка; ответ «pprof» правильный, но начинать надо с того, какой профиль.
- «Как снять профиль?» — три способа, и у каждого своё место.
- «Чем flat отличается от cum?» — здесь проверяют, читал ли кандидат профиль сам.
- «Как найти утечку памяти?» — про два вида значений в профиле кучи.
- «Почему профиль блокировок пустой?» — вопрос, на котором спотыкаются и те, кто профили читал.
- «Можно ли профилировать в продакшене?» — про цену и про то, кому
открыт
/debug/pprof.
Стержень у лестницы один: профиль — это выборка определённого события, и у каждого профиля своё событие, своя частота и своё значение по умолчанию. Почти все ошибки чтения — от того, что смотрят не в тот профиль.
База: профиль — это выборка, а не учёт
Профилировщик не записывает каждый вызов: это стоило бы слишком дорого. Почти каждый профиль — выборка (исключение — профиль горутин, о нём ниже): с какой-то частотой берётся сэмпл — снимок стека в момент события — и складывает одинаковые стеки. Что считается событием, зависит от профиля:
| профиль | событие | по умолчанию |
|---|---|---|
| CPU | 100 раз в секунду процессорного времени (go1.24.7) | включается вызовом |
| heap / allocs | в среднем одно выделение на 512 КиБ | включён |
| goroutine | все горутины в момент снятия | включён |
| block | ожидание на примитивах синхронизации | выключен |
| mutex | борьба за мьютекс: стек того, кто его держал, в точке Unlock | выключен |
Снимают профиль тремя способами, и у каждого своё место:
- из теста или бенчмарка —
go test -bench . -cpuprofile cpu.out, рядом-memprofile,-blockprofile,-mutexprofile. Это место для воспроизводимой нагрузки; - из работающего сервиса —
import _ "net/http/pprof", и по адресам/debug/pprof/отдаются все профили. Это место для настоящей нагрузки; - из кода —
runtime/pprof:StartCPUProfile,Lookup("heap").WriteTo. Так устроены оба способа выше; руками это пишут, когда нужен профиль конкретного участка.
Читают профиль одинаково: go tool pprof — с режимами -top, -list,
-web.
И сразу — какой инструмент на какой вопрос отвечает:
| инструмент | на какой вопрос отвечает |
|---|---|
| профиль CPU | где процессор тратит время |
| heap / allocs | что удерживается в куче Go и кто выделяет |
| block | где горутины стоят на примитивах синхронизации |
| mutex | где борются за мьютексы и кто их держит |
| goroutine | какие горутины есть сейчас и на чём они стоят |
трассировка (go tool trace) | что происходило во времени: планировщик, сеть, задержки |
Ответ уровня Junior на «как найти, что тормозит»: снять профиль CPU — из
бенчмарка через go test -cpuprofile или из сервиса через net/http/pprof — и
открыть его в go tool pprof; наверху будут функции, в которых программа
проводит больше всего времени.
Механизм 1: профиль CPU — выборка, в go1.24.7 — 100 раз в секунду
В runtime/pprof частоту не выбирают: StartCPUProfile её не принимает, она
зашита. В исходнике рядом с ней объяснение:
100 Hz is a reasonable choice: it is frequent enough to produce useful data,
rare enough not to bog down the system, and a nice round number to make it easy
to convert sample counts to seconds.
100 Гц — разумный выбор: достаточно часто, чтобы получить полезные данные, достаточно редко, чтобы не затормозить систему, и круглое число, с которым легко переводить число сэмплов в секунды.
Значит, при номинальных 100 Гц один сэмпл соответствует примерно 10 мс процессорного времени — в сумме, по статистике, а не по секундомеру у каждой функции. Две секунды работы одной горутины:
длительность работы 2.11 с
сэмплов в профиле 203
сэмплов в секунду 96
Из этого числа следуют две вещи, которые стоит уметь сказать.
Короткие функции профиль может не увидеть. Функция, проработавшая 5 мс за весь запуск, получит в среднем полсэмпла — то есть ноль или один. Профиль отвечает на вопрос «где программа проводит много времени», а не «вызывалась ли функция».
«Total samples» в выводе — время, а не число. В другом прогоне — раздел 1
internals.go, другая нагрузка той же длины — go tool pprof -top печатает:
Duration: 2.12s, Total samples = 2070ms (97.61%)
2070 мс — это 207 сэмплов, пересчитанных по номинальным 10 мс. Процент — доля процессорного времени от
стенного: 97,61 %, потому что одна горутина занимает не больше одного ядра.
Если занятых горутин несколько, процент может превысить 100 — прогон
bench/goprof/cores.go:
одна занятая горутина: Duration: 2.11s, Total samples = 1.97s (93.39%)
две занятые горутины: Duration: 2.15s, Total samples = 3.89s (181.35%)
На двух ядрах две горутины набрали почти вдвое больше процессорного времени, чем прошло по часам.
Механизм 2: flat и cum
Вот таблица, ради которой профиль и открывают. Функция handle сама ничего
не делает — только зовёт hashAll (тяжёлая работа) и parse (лёгкая). Строки
выбраны из полного списка -top, поэтому колонку sum% — накопленный flat%
в исходном порядке — здесь не читайте:
2) FLAT ПРОТИВ CUM НА ОДНОЙ И ТОЙ ЖЕ ЦЕПОЧКЕ
────────────────────────────────────────────
flat flat% sum% cum cum%
0 0% 94.20% 1750ms 84.54% main.hashAll
50ms 2.42% 77.29% 50ms 2.42% main.parse
0 0% 94.20% 1800ms 86.96% main.handle
- flat — сэмплы, в которых функция была на вершине стека: время в её собственном коде.
- cum — сэмплы, в которых функция была где угодно в стеке: она сама плюс всё, что она вызвала.
У handle flat 0 и cum 86,96 %: сама функция не тратит ничего, но почти всё
время программа проводит под ней. Сортировка по flat её бы не показала,
сортировка по cum поставила бы наверх.
Обратите внимание и на hashAll: у неё flat тоже 0. Тяжёлая работа сидит
глубже — в функциях crypto/sha256, и наверху -top стоят именно они:
1310ms 63.29% 63.29% 1310ms 63.29% crypto/internal/fips140/sha256.blockAVX2
Отсюда порядок чтения, который стоит назвать на собеседовании:
- flat — где процессор выполнялся непосредственно;
- cum — какие пути вызовов ответственны за это время.
Нужны оба. По flat видно горячий код — здесь blockAVX2; по cum — чьё решение
его вызывать: handle через hashAll. Часто выигрыш не в том, чтобы ускорить
верх flat, а в том, чтобы реже вызывать путь, который виден по cum.
Механизм 3: профиль кучи — два разных вида значений
В каждой записи профиля кучи четыре вида значений — выделено и удерживается,
в объектах и в байтах, — и -sample_index выбирает, какой из них показать.
Два главных отвечают на разные вопросы. Документация определяет, на какой
момент снят профиль:
The heap profile reports statistics as of the most recently completed garbage
collection; …
Профиль кучи показывает состояние на последнюю завершённую сборку мусора; …
Две функции: churn выделяет много и ничего не держит, retain выделяет
немного и держит всё:
3) ПРОФИЛЬ КУЧИ: alloc_space ПРОТИВ inuse_space
───────────────────────────────────────────────
alloc_space — сколько выделено за всё время:
596.83MB 76.83% 76.83% 596.83MB 76.83% main.churn
16.19MB 2.08% 99.58% 16.19MB 2.08% main.retain
inuse_space — сколько удерживается сейчас:
в выводе нет строки main.churn
16.19MB 97.00% 97.00% 16.19MB 97.00% main.retain
Функция, выделившая почти 600 МБ, в inuse_space отсутствует вовсе. Это
не ошибка профиля, а смысл двух видов значений:
inuse_space(по умолчанию уheap) — сколько байт кучи Go удерживается сейчас. Здесь видна утечка памяти кучи: что растёт от снятия к снятию и не уходит.alloc_space(по умолчанию уallocs) — всё, что выделялось. По ней ищут давление на сборщик: кто создаёт работу для сборки мусора, даже ничего не удерживая.
Но inuse_space — не универсальный детектор утечек:
inuse_objects— тот же срез в штуках, а не в байтах: утечка множества мелких объектов в байтах может теряться;- утечка горутин — это профиль горутин (Механизм 5): в профиле кучи она видна разве что косвенно, по тому, что эти горутины удерживают;
- память вне кучи Go — выделенная через cgo, ядром, отображённая в память —
heap-профиль Go не показывает вовсе. Если процесс растёт, а
inuse_spaceнет, искать надо там.
Профиль кучи — тоже выборка: в среднем одно выделение на
runtime.MemProfileRate = 524288 байт. И о единицах: «MB» в выводе pprof — это
2²⁰ байт, то есть МиБ. Мелкое выделение, сделанное один раз,
в профиль может не попасть, а частое попадёт пропорционально объёму.
Механизм 4: профили, которые выключены по умолчанию
Четыре горутины дерутся за один мьютекс — конкуренция настоящая. Профили блокировок и мьютексов:
4) ПРОФИЛИ БЛОКИРОВОК И МЬЮТЕКСОВ ПО УМОЛЧАНИЮ
──────────────────────────────────────────────
без настройки: block 0 записей, mutex 0 записей
после SetBlockProfileRate(1) и SetMutexProfileFraction(1):
block 3 записей, mutex 2 записей
Без настройки оба пусты. Это самая частая ловушка: сервис с net/http/pprof,
открыли /debug/pprof/block — пусто, сделали вывод «блокировок нет».
Отвечают они на разные вопросы:
- block — где и сколько горутины стояли на примитивах синхронизации:
каналах, мьютексах,
WaitGroup. Стек — того, кто ждал; - mutex — где шла борьба за мьютекс, и стек того, кто его держал,
в точке
Unlock: где укорачивать критический участок.
Включаются они так:
To include every blocking event in the profile, pass rate = 1. To turn off
profiling entirely, pass rate <= 0.
Чтобы включить в профиль каждое событие блокировки, передайте rate = 1. Чтобы выключить профилирование полностью, передайте rate <= 0.
Значение 1 — «записывать всё» — удобно для отладки, но не бесплатно. На крайних нагрузках, где почти вся работа — блокировки или захваты:
1) ПРОФИЛЬ БЛОКИРОВОК: SetBlockProfileRate(1)
─────────────────────────────────────────────
серия выключен «всё» кратн.
1 пинг-понг, 200 000 обменов 88.7 мс 145.1 мс x1.64
2 пинг-понг, 200 000 обменов 82.5 мс 146.6 мс x1.78
3 пинг-понг, 200 000 обменов 75.1 мс 142.8 мс x1.90
2) ПРОФИЛЬ МЬЮТЕКСОВ: SetMutexProfileFraction(1)
────────────────────────────────────────────────
серия выключен «всё» кратн.
1 4 горутины × 200 000 захватов 16.5 мс 25.1 мс x1.52
2 4 горутины × 200 000 захватов 33.7 мс 54.1 мс x1.61
3 4 горутины × 200 000 захватов 29.2 мс 39.3 мс x1.35
Прогон bench/goprof/interfere.go: ×1,64–1,90 для блокировок и ×1,35–1,61 для
мьютексов. На сервисе, где блокировки — малая часть работы, доля будет меньше,
но режим «каждое событие» в продакшене — осознанное решение, а не значение по
умолчанию. Обычно ставят большее значение и получают выборку.
И различие, которое стоит знать. В go test флаг -blockprofile включает
запись сам: если -blockprofilerate не указан, записываются все события
блокировки. В сервисе такого нет — вызов runtime.SetBlockProfileRate
должен быть в вашем коде.
Механизм 5: профиль горутин — и почему одного снимка мало
Сотня горутин ждёт канала, в который никто никогда не напишет:
5) ПРОФИЛЬ ГОРУТИН НАХОДИТ УТЕЧКУ
─────────────────────────────────
горутин до: 1, после: 101
в профиле: 100 горутин с одним и тем же стеком:
main.main.func2 internals.go:246
Профиль горутин — не выборка, а снимок всех горутин сразу, сгруппированных по стеку. Один снимок показывает подозрительное скопление: сто горутин на одной и той же операции. Но сто горутин могут честно ждать работы — пул обработчиков выглядит так же. Утечку доказывает динамика:
снимок A → подождать → снимок B
тот же стек вырос и не вернулся обратноСтрока internals.go:246 — место, где они ждут; причину ищут там.
Почему горутины утекают и почему -race такую утечку не ловит, разобрано в
уроке о каналах. Здесь — инструмент: /debug/pprof/goroutine?debug=1 в
работающем сервисе, и сравнение двух снятий через несколько минут.
Глубже: сколько стоит профилирование и кому открыт /debug/pprof
Одна и та же работа — 400 вызовов handle — с профилем CPU и без, три серии:
6) ЦЕНА ПРОФИЛЯ CPU ДЛЯ САМОЙ ПРОГРАММЫ
───────────────────────────────────────
серия 400 вызовов без профиля с профилем CPU надбавка
1 228.6 мс 219.4 мс -4.0%
2 223.3 мс 223.5 мс 0.1%
3 222.1 мс 221.0 мс -0.5%
Надбавка меняет знак от серии к серии: она меньше шума замера. Причина
— та же частота: сто снимков стека в секунду — малая доля работы на фоне
800 000 вызовов sha256, которые 400 вызовов handle делают за ≈222 мс.
Отсюда практическое различие, о котором спрашивают: профиль CPU обычно
достаточно лёгок для ограниченного снятия в продакшене — по запросу, через
/debug/pprof/profile (по умолчанию 30 секунд, длительность задаёт
?seconds=N). А -race, который на замеренной нагрузке стоил ×4,8–5,0 по
памяти и ×7,6–12 по времени, на всём парке не держат (это разобрано в уроке о
гонках данных).
И граница у этих чисел: замер снят при GOMAXPROCS=2 на одной нагрузке go1.24.7. Надбавки выше шума на ней не обнаружено — это результат замера, а не универсальное число: цена зависит от нагрузки.
Инструмент не нейтрален. Профили влияют и на программу, и друг на друга. Профиль памяти, записывающий каждое выделение, сам становится строкой профиля CPU:
3) ПРОФИЛЬ ПАМЯТИ: MemProfileRate = 1
─────────────────────────────────────
серия выключен «всё» кратн.
1 2 000 000 выделений по 64 Б 67.0 мс 3488.9 мс x52.03
2 2 000 000 выделений по 64 Б 59.8 мс 3734.4 мс x62.43
3 2 000 000 выделений по 64 Б 71.4 мс 3599.8 мс x50.43
доля runtime.mProf_Malloc в профиле CPU той же нагрузки (cum%):
MemProfileRate по умолчанию: нет в профиле
MemProfileRate = 1: 76.64%
Нагрузка нарочно крайняя — одни мелкие выделения, — но вывод общий: снятый
при MemProfileRate = 1 профиль CPU на 76,64 % показывает работу самого
профилировщика памяти. Поэтому подробные режимы не включают все сразу и
читают каждый профиль с поправкой на то, что было включено рядом.
Кому открыт /debug/pprof. Это обязательная вторая половина совета
«снимайте в продакшене». Импорт net/http/pprof регистрирует обработчики на
http.DefaultServeMux, и сервис, отдающий его на рабочий порт, отдаёт и
профили — без какой-либо проверки доступа:
сервис зарегистрировал только /hello и отдаёт DefaultServeMux
GET /debug/pprof/cmdline -> 200, аргументы: ["-db-password=demo-secret"]
GET /debug/pprof/goroutine?debug=2 -> 200, строк «файл:строка»: 53
GET /debug/pprof/heap -> 200
Прогон bench/goprof/exposure.go, пароль в аргументах — выдуманный. Наружу
уходят аргументы запуска, стеки с путями к исходникам и профили памяти. Поэтому
/debug/pprof — служебная поверхность: отдельный слушатель (пример из
документации пакета слушает localhost:6060), доступ только из внутренней
сети или через аутентификацию, никакого выхода в публичный интернет.
Как отвечать на собеседовании
Короткий ответ: снять профиль CPU — из бенчмарка через go test -cpuprofile или из сервиса через net/http/pprof — и открыть в go tool pprof -top. Профилировщик сэмплирующий, поэтому наверху окажутся функции, где
программа проводит больше всего времени, а короткие вызовы могут не попасть
вовсе. Частоту — 100 раз в секунду в go1.24.7 — называют как деталь
реализации, а не как свойство языка.
Этого достаточно, чтобы ответить верно. Дальше — то, что добавляют, если собеседник копает.
Если интервьюер копает глубже
На «чем flat отличается от cum» — пример с диспетчером. «flat — где процессор выполнялся непосредственно, cum — какие пути вызовов за это отвечают. У функции, которая только раздаёт работу, flat ноль, а cum почти весь профиль — у меня 86,96 %. Смотрят оба: часто дешевле реже вызывать путь, чем ускорять горячую функцию».
На «как искать утечку памяти» — два вида значений. «inuse_space — что
удерживается на последнюю сборку; утечку ищут там, сравнивая два снятия.
alloc_space — всё выделенное; по ней ищут давление на сборщик. Функция,
выделившая шестьсот мегабайт и ничего не удержавшая, в inuse_space не
видна вовсе. А утечку горутин ищут в профиле горутин, и память вне кучи Go
heap-профиль не покажет».
На «почему профиль блокировок пустой» — он выключен. «Block и mutex по
умолчанию не пишутся: у меня при настоящей конкуренции было 0 записей. Нужно
вызвать runtime.SetBlockProfileRate и SetMutexProfileFraction. В
go test -blockprofile это включается само, в сервисе — нет».
На утечку горутин — профиль горутин, два снятия. «Один снимок показывает скопление на одном стеке; утечка — когда этот стек растёт от снятия к снятию и не возвращается. Стек показывает, на чём они стоят».
На «можно ли в продакшене» — цена и доступ. «Профиль CPU — как правило,
да, ограниченным снятием по запросу: на моей нагрузке надбавка не вышла за
шум, но это не универсальное число. /debug/pprof — только на служебном
слушателе, не наружу. Весь парк под -race — нет».
Дальше спросят
Чем профиль отличается от трассировки (go tool trace)?
Профиль отвечает на вопрос «где тратится время» и складывает одинаковые стеки, теряя порядок событий. Трассировка записывает события во времени: когда горутина стала готова, когда её запустил планировщик, когда она заблокировалась, когда шла сборка мусора.
Отсюда разделение труда: «функция медленная» — профиль; «запрос то быстрый, то медленный, а CPU свободен» — трассировка, потому что причина обычно в ожидании, а ожидание профиль CPU не видит вовсе.
Почему медленная функция не видна в профиле CPU, хотя всё тормозит?
Потому что профиль CPU видит только время на процессоре. Горутина, которая ждёт ответа базы, мьютекса или канала, процессор не занимает и в профиль CPU не попадает.
Если CPU загружен слабо, а запросы медленные, смотрят профили блокировок и мьютексов — не забыв их включить — или трассировку. Профиль CPU в этом случае честно покажет, что программа почти ничего не делает.
Как сравнить два профиля — до и после оптимизации?
go tool pprof -diff_base before.out after.out показывает разницу: что стало
дороже, что дешевле. Для бенчмарков удобнее benchstat по нескольким
прогонам каждого варианта — он считает и разброс, и то, значима ли разница.
Главное правило здесь то же, что и в замерах урока: одно сравнение одного прогона ничего не доказывает. Надбавка профиля CPU в этом уроке от серии к серии меняла знак — ровно так выглядит разница, которой нет.
Что такое PGO и при чём тут профиль?
Сборка с оптимизацией по профилю: профиль CPU из продакшена кладут в каталог
пакета main под именем default.pgo, и go build применяет его к этому
пакету и его зависимостям — компилятор оптимизирует по нему горячие пути.
Связь с уроком прямая: это ещё одна причина снимать профиль CPU в продакшене, а снимать его там позволяет то, что его цена мала. И оговорка: профиль должен быть представительным. Профиль, снятый на нетипичной нагрузке или устаревший после изменений кода, оптимизирует не те горячие пути.
Частые заблуждения
Профилировщик записывает каждый вызов функции
Он сэмплирует: профиль CPU в go1.24.7 — 100 раз в секунду, это зашито в исходнике runtime/pprof. В прогоне — 203 сэмпла за 2,11 с. Функция, работавшая несколько миллисекунд, в профиль может не попасть.
Самая дорогая функция — та, что наверху по cum
Наверху по cum стоят и диспетчеры, которые сами ничего не делают: у handle flat 0 и cum 86,96 %. flat — где процессор выполнялся непосредственно; cum — какие пути вызовов за это отвечают. Нужны оба.
Если функция много выделяет, она видна в профиле кучи
Только в alloc_space. В inuse_space — выборке по умолчанию у heap — функция, выделившая 596,83 МБ и ничего не удержавшая, отсутствует вовсе.
Пустой профиль блокировок значит, что блокировок нет
Профили блокировок и мьютексов выключены по умолчанию: при настоящей конкуренции — 0 записей, после SetBlockProfileRate(1) и SetMutexProfileFraction(1) — 3 и 2.
Профилирование в продакшене слишком дорогое
Не обязательно. Профиль CPU на нашей нагрузке дал надбавку −4,0 %, +0,1 % и −0,5 % — в пределах шума; это не универсальное число, но ограниченное снятие по запросу обычно по карману. Дороги -race и режимы «записывать всё»: MemProfileRate = 1 на крайней нагрузке замедлил её в 50–62 раза.
Если inuse_space не растёт, утечки нет
Утечки кучи Go нет. Горутины утекают в профиле горутин, а память, выделенная через cgo или ядром, в heap-профиль Go не попадает вовсе.
/debug/pprof безопасно оставить на рабочем порту — там только статистика
В прогоне без единой проверки доступа отдались аргументы запуска с паролем, стеки с путями к исходникам и профиль памяти. Это служебная поверхность: отдельный слушатель и закрытая сеть.
Профиль CPU покажет, почему запрос медленный
Только если запрос медленный из-за вычислений. Ожидание базы, мьютекса или канала процессор не занимает, и в профиль CPU не попадает. Для ожидания — профили блокировок и мьютексов или трассировка.
Практика
Две задачи. Сначала ответьте, потом сверьтесь с настоящим выводом: в обеих правильный ответ взят из прогона скрипта, а не назначен.
Практика · что напечатает
fmt.Println(pprof.Lookup("block").Count())
before := pprof.Lookup("goroutine").Count()
never := make(chan int)
for i := 0; i < 100; i++ {
go func() { <-never }()
}
time.Sleep(50 * time.Millisecond)
fmt.Println(pprof.Lookup("goroutine").Count() - before)
churn(20000)
runtime.GC()
fmt.Println(strings.Contains(string(out), "main.churn"))Практика · оцените
Проверка знаний
Функция handle только вызывает две другие и ничего не делает сама. Что у неё в профиле CPU?
Это не пересказ и не отдельный текст: всё ниже взято из самой статьи — её выжимка, заголовки разборов, колонка «на самом деле» и таблица версий. Поэтому разойтись со статьёй эти тезисы не могут.
Суть
- Профилировщик Go — сэмплирующий: он не считает каждый вызов, а с некоторой частотой смотрит, что сейчас выполняется. Это и есть ответ для собеседования. Частота — деталь реализации: в
runtime/pprofgo1.24.7 профиль CPU снимает 100 сэмплов в секунду, и число зашито в исходнике. Замер: 203 сэмпла за 2,11 с работы одной горутины. Отсюда главное правило чтения: профиль — выборка, и функция, работавшая 5 мс, в нём может не появиться вовсе. - Дальше — то, что отличает понимание от пересказа. В выводе
go tool pprofдве колонки времени: flat — время в самой функции, cum — вместе с вызванными. У функции-диспетчера flat 0, а cum 1800 мс — 86,96 % профиля. У профиля кучи два разных вида значений: inuse_space — что удерживается сейчас, и alloc_space — что выделено за всё время. Функция, выделившая 596,83 МБ и ничего не удержавшая, вinuse_spaceотсутствует: утечку ищут по одному виду, давление на сборщик — по другому. - Два профиля выключены по умолчанию. Профили блокировок и мьютексов при настоящей конкуренции дали 0 записей, пока их не включили вызовами
SetBlockProfileRateиSetMutexProfileFraction. Пустой профиль не значит «блокировок нет». Профиль горутин показывает подозрительное скопление: сто горутин, ждущих канала, в который никто не пишет, лежат под одним стеком; утечку доказывает то, что этот стек растёт от снятия к снятию. Профиль CPU обычно достаточно лёгок для ограниченного снятия в продакшене: на нашей нагрузке надбавка не вышла за шум замера, но это не универсальное число. И/debug/pprof— служебная поверхность: наружу его не открывают.
На самом деле
- Он сэмплирует: профиль CPU в go1.24.7 — 100 раз в секунду, это зашито в исходнике
runtime/pprof. В прогоне — 203 сэмпла за 2,11 с. Функция, работавшая несколько миллисекунд, в профиль может не попасть. - Наверху по cum стоят и диспетчеры, которые сами ничего не делают: у
handleflat 0 и cum 86,96 %. flat — где процессор выполнялся непосредственно; cum — какие пути вызовов за это отвечают. Нужны оба. - Только в
alloc_space. Вinuse_space— выборке по умолчанию уheap— функция, выделившая 596,83 МБ и ничего не удержавшая, отсутствует вовсе. - Профили блокировок и мьютексов выключены по умолчанию: при настоящей конкуренции — 0 записей, после
SetBlockProfileRate(1)иSetMutexProfileFraction(1)— 3 и 2. - Не обязательно. Профиль CPU на нашей нагрузке дал надбавку −4,0 %, +0,1 % и −0,5 % — в пределах шума; это не универсальное число, но ограниченное снятие по запросу обычно по карману. Дороги
-raceи режимы «записывать всё»:MemProfileRate = 1на крайней нагрузке замедлил её в 50–62 раза. - Утечки кучи Go нет. Горутины утекают в профиле горутин, а память, выделенная через cgo или ядром, в heap-профиль Go не попадает вовсе.
- В прогоне без единой проверки доступа отдались аргументы запуска с паролем, стеки с путями к исходникам и профиль памяти. Это служебная поверхность: отдельный слушатель и закрытая сеть.
- Только если запрос медленный из-за вычислений. Ожидание базы, мьютекса или канала процессор не занимает, и в профиль CPU не попадает. Для ожидания — профили блокировок и мьютексов или трассировка.
Что разобрано
- Что здесь на самом деле спрашивают
- База: профиль — это выборка, а не учёт
- Механизм 1: профиль CPU — выборка, в go1.24.7 — 100 раз в секунду
- Механизм 2: flat и cum
- Механизм 3: профиль кучи — два разных вида значений
- Механизм 4: профили, которые выключены по умолчанию
- Механизм 5: профиль горутин — и почему одного снимка мало
- Глубже: сколько стоит профилирование и кому открыт /debug/pprof
- Как отвечать на собеседовании
- Дальше спросят
- Частые заблуждения
- Практика
- Проверка знаний
Источники и что читать дальше
4 ИСТОЧНИКА
- Пакет runtime/pprofОфициальная документация. Какие профили есть: «goroutine - stack traces of all current goroutines» (goroutine — стеки всех текущих горутин), «heap - a sampling of memory allocations of live objects» (heap — выборка выделений памяти живых объектов), «allocs - a sampling of all past memory allocations» (allocs — выборка всех прошлых выделений памяти), «block - stack traces that led to blocking on synchronization primitives» (block — стеки, приведшие к блокировке на примитивах синхронизации), «mutex - stack traces of holders of contended mutexes» (mutex — стеки держателей мьютексов, за которые шла борьба). И про профиль кучи: «The heap profile reports statistics as of the most recently completed garbage collection» (Профиль кучи показывает состояние на последнюю завершённую сборку мусора).https://pkg.go.dev/runtime/pprof
- Исходник runtime/pprof — StartCPUProfile, go1.24.7Официальная документация. Откуда частота профиля CPU и почему её не передают в StartCPUProfile: «100 Hz is a reasonable choice: it is frequent enough to produce useful data, rare enough not to bog down the system, and a nice round number to make it easy to convert sample counts to seconds» (100 Гц — разумный выбор: достаточно часто, чтобы получить полезные данные, достаточно редко, чтобы не затормозить систему, и круглое число, с которым легко переводить число сэмплов в секунды). И дальше: «Instead of requiring each client to specify the frequency, we hard code it» (Вместо того чтобы требовать от каждого клиента указать частоту, мы её зашиваем). Сразу после комментария —
const hz = 100.https://github.com/golang/go/blob/go1.24.7/src/runtime/pprof/pprof.go - Пакет runtime — MemProfileRate, SetBlockProfileRate, SetMutexProfileFractionОфициальная документация. Почему профиль кучи — выборка: «The profiler aims to sample an average of one allocation per MemProfileRate bytes allocated» (Профилировщик старается брать в среднем одно выделение на каждые MemProfileRate выделенных байт); значение по умолчанию — 512 * 1024. Как включить профиль блокировок: «To include every blocking event in the profile, pass rate = 1. To turn off profiling entirely, pass rate <= 0» (Чтобы включить в профиль каждое событие блокировки, передайте rate = 1. Чтобы выключить профилирование полностью, передайте rate <= 0).https://pkg.go.dev/runtime#SetBlockProfileRate
- Пакет net/http/pprofОфициальная документация. Как профили обычно попадают в сервис: «The package is typically only imported for the side effect of registering its HTTP handlers. The handled paths all begin with /debug/pprof/» (Пакет обычно импортируют только ради побочного эффекта — регистрации его HTTP-обработчиков. Все обслуживаемые пути начинаются с /debug/pprof/).https://pkg.go.dev/net/http/pprof