Deep Engineering
Продвинутый·Опубликовано·30 МИН

Профилирование в 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, частота сэмплов;
  • профили блокировок, мьютексов и горутин, цена профилирования.

Что здесь на самом деле спрашивают

Лестница почти всегда такая:

  1. «Как найти, что тормозит?» — разминка; ответ «pprof» правильный, но начинать надо с того, какой профиль.
  2. «Как снять профиль?» — три способа, и у каждого своё место.
  3. «Чем flat отличается от cum?» — здесь проверяют, читал ли кандидат профиль сам.
  4. «Как найти утечку памяти?» — про два вида значений в профиле кучи.
  5. «Почему профиль блокировок пустой?» — вопрос, на котором спотыкаются и те, кто профили читал.
  6. «Можно ли профилировать в продакшене?» — про цену и про то, кому открыт /debug/pprof.

Стержень у лестницы один: профиль — это выборка определённого события, и у каждого профиля своё событие, своя частота и своё значение по умолчанию. Почти все ошибки чтения — от того, что смотрят не в тот профиль.

База: профиль — это выборка, а не учёт

Профилировщик не записывает каждый вызов: это стоило бы слишком дорого. Почти каждый профиль — выборка (исключение — профиль горутин, о нём ниже): с какой-то частотой берётся сэмпл — снимок стека в момент события — и складывает одинаковые стеки. Что считается событием, зависит от профиля:

профильсобытиепо умолчанию
CPU100 раз в секунду процессорного времени (go1.24.7)включается вызовом
heap / allocsв среднем одно выделение на 512 КиБвключён
goroutineвсе горутины в момент снятиявключён
blockожидание на примитивах синхронизациивыключен
mutexборьба за мьютекс: стек того, кто его держал, в точке Unlockвыключен
контракт языкаСписок профилей, частота CPU-профиля и значения по умолчанию — документация и исходник runtime/pprof и runtime. Числа ниже — прогоны bench/goprof на одной машине.

Снимают профиль тремя способами, и у каждого своё место:

  • из теста или бенчмарка — 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 раз в секунду

деталь реализации · Go 1.24Частота 100 Гц — константа в исходнике runtime/pprof конкретной версии, а не гарантия языка. Гарантия одна: профиль CPU — выборка.

В 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 Гц — разумный выбор: достаточно часто, чтобы получить полезные данные, достаточно редко, чтобы не затормозить систему, и круглое число, с которым легко переводить число сэмплов в секунды.

— Исходник runtime/pprof, go1.24.7

Значит, при номинальных 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; …

перевод

Профиль кучи показывает состояние на последнюю завершённую сборку мусора; …

— Пакет runtime/pprof

Две функции: 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.

— Пакет runtime — SetBlockProfileRate

Значение 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

Профиль горутин — не выборка, а снимок всех горутин сразу, сгруппированных по стеку. Один снимок показывает подозрительное скопление: сто горутин на одной и той же операции. Но сто горутин могут честно ждать работы — пул обработчиков выглядит так же. Утечку доказывает динамика:

TEXT
снимок A → подождать → снимок B
тот же стек вырос и не вернулся обратно

Строка internals.go:246 — место, где они ждут; причину ищут там.

Почему горутины утекают и почему -race такую утечку не ловит, разобрано в уроке о каналах. Здесь — инструмент: /debug/pprof/goroutine?debug=1 в работающем сервисе, и сравнение двух снятий через несколько минут.

Глубже: сколько стоит профилирование и кому открыт /debug/pprof

наблюдение замераbench/goprof/internals.go, go1.24.7 linux/amd64, GOMAXPROCS=2; в каждой серии — лучший из 5 кругов. Знак надбавки от серии к серии меняется — это и есть результат.

Одна и та же работа — 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 не попадает. Для ожидания — профили блокировок и мьютексов или трассировка.

Практика

Две задачи. Сначала ответьте, потом сверьтесь с настоящим выводом: в обеих правильный ответ взят из прогона скрипта, а не назначен.

Практика · что напечатает

Четыре горутины только что дрались за мьютекс; профиль блокировок никто не включал. Потом запускается сотня горутин, ждущих канала, в который никто не пишет. Потом churn выделяет 20 000 буферов по 4 КиБ и ничего не держит, и снимается профиль кучи. Что напечатает этот код?
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"))

Практика · оцените

Одна горутина 2 секунды занята вычислениями, профиль CPU включён. Сколько сэмплов окажется в профиле?
сэмплов

Проверка знаний

Вопрос 1 из 5

Функция handle только вызывает две другие и ничего не делает сама. Что у неё в профиле CPU?

Источники и что читать дальше

4 ИСТОЧНИКА

  1. Пакет 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
  2. Исходник 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
  3. Пакет 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
  4. Пакет 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