Профилирование и производительность
Кратко о теме
Заголовок раздела «Кратко о теме»Профилирование в Go — это не отдельный внешний инструмент, а часть рантайма. Рантайм сам собирает статистику по нескольким осям (CPU, аллокации, блокировки, мьютексы, горутины, потоки ОС), пакет runtime/pprof умеет сериализовать её в формат protobuf profile.proto, net/http/pprof — отдавать по HTTP, а go tool pprof — визуализировать. Ключевое свойство почти всех профилей — они сэмплирующие: рантайм не считает каждое событие, а берёт выборку (CPU — 100 раз в секунду на поток, heap — примерно раз на каждые 512 КБ выделенной памяти) и потом экстраполирует. Отсюда два практических следствия: overhead маленький и профиль можно снимать в проде, но короткие профили на редких событиях статистически шумные.
Правильная модель в голове: у вас есть четыре независимых вопроса и на каждый отвечает свой инструмент. «Куда уходит процессорное время?» — CPU-профиль. «Кто занимает/выделяет память?» — heap-профиль (inuse_space для «что живо сейчас», alloc_space для «кто вообще мусорит»). «Почему приложение не грузит CPU, но медленное?» — это не CPU-профиль, а block/mutex-профиль и execution trace: время уходит на ожидание, а wall-clock ожидания в CPU-профиле не видно вообще. «Почему растёт число горутин / где они застряли?» — goroutine-профиль. Самая частая ошибка на собеседовании — пытаться объяснить любую проблему CPU-профилем.
Отдельная ось — бенчмарки. go test -bench с -benchmem даёт воспроизводимые числа ns/op, B/op, allocs/op, а benchstat из golang.org/x/perf показывает, значима ли разница между «до» и «после» статистически. Правильный цикл оптимизации: воспроизвести проблему бенчмарком или профилем из прода → измерить → изменить одно место → перемерить → сравнить. Оптимизация без измерения на собеседовании считается красным флагом почти так же, как незнание pprof.
Начиная с Go 1.21 профили используются не только людьми, но и компилятором: PGO (profile-guided optimization) читает default.pgo рядом с main-пакетом и по CPU-профилю из прода агрессивнее инлайнит горячие функции и девиртуализирует вызовы через интерфейсы. Типичный выигрыш — единицы процентов «бесплатно», без изменения кода. Это удобная вещь, чтобы упомянуть в конце ответа про pprof: профиль из прода имеет ценность и после того, как вы починили конкретное узкое место.
Вопросы и ответы
Заголовок раздела «Вопросы и ответы»Что такое профилирование в Go?
Заголовок раздела «Что такое профилирование в Go?»Коротко. Профилирование — сбор статистики о том, как программа расходует ресурсы (процессорное время, память, время ожидания на блокировках), чтобы найти узкое место фактически, а не по догадке. В Go это встроено в рантайм: runtime/pprof и net/http/pprof отдают профили, go tool pprof их анализирует.
Глубже. Отличие профилирования от трассировки и метрик: метрики (Prometheus) отвечают «плохо ли сейчас», трассировка (OpenTelemetry, runtime/trace) — «на каком этапе и в каком порядке», профиль — «в каких строках кода это происходит». Стандартные профили: profile (CPU), heap (живая память), allocs (все аллокации за время жизни процесса), goroutine, block, mutex, threadcreate. Все, кроме goroutine, — сэмплирующие. Профиль хранится в формате protobuf (profile.proto) и состоит из набора сэмплов: стек вызовов + значения (например, наносекунды CPU или байты). Именно поэтому профили можно складывать, вычитать (-base, -diff_base) и агрегировать по нескольким инстансам.
Profiling. Differences between profiling on desktop and server a) pprof - a performance analysis tool
Заголовок раздела «Profiling. Differences between profiling on desktop and server a) pprof - a performance analysis tool»Коротко. Технически профилировщик один и тот же, отличается контекст: локально вы профилируете короткий воспроизводимый сценарий или бенчмарк и смотрите результат интерактивно, а на сервере — живой процесс под реальной нагрузкой, поэтому важны низкий overhead, безопасность endpoint’а и непрерывный сбор профилей в хранилище.
Глубже. Практические различия, которые стоит проговорить. (1) Источник: локально — go test -cpuprofile cpu.out, на сервере — HTTP /debug/pprof/profile?seconds=30. (2) Нагрузка: локальный прогон не воспроизводит реальный микс запросов, кэш-локальность и конкуренцию за ядра, поэтому «локально быстро, в проде медленно» — норма; профиль из прода почти всегда честнее. (3) Overhead и риск: на сервере нельзя включать SetBlockProfileRate(1) на постоянку, а goroutine-профиль на сотнях тысяч горутин заметно бьёт по latency. (4) Безопасность: net/http/pprof в init() регистрируется в http.DefaultServeMux, и если основной сервер слушает на нём, вы публично отдаёте /debug/pprof/ — включая cmdline и возможность запустить 30-секундный профиль. На проде это вешают на отдельный порт за внутренним периметром. (5) Символизация: профиль ссылается на адреса, разрешаемые по pclntab бинаря; при анализе нужен тот же бинарник (go tool pprof ./app profile.pb.gz). (6) На сервере имеет смысл continuous profiling — Grafana Pyroscope, Parca, Datadog/GCP Profiler: они постоянно снимают короткие профили со всех инстансов, и вы можете посмотреть профиль «за вчера в 3 часа ночи», когда инцидент уже прошёл.
Do you use profiling? What profiles do you use?
Заголовок раздела «Do you use profiling? What profiles do you use?»Коротко. Каркас ответа: да, регулярно; по умолчанию во всех сервисах включён net/http/pprof на служебном порту; на постоянку снимаются CPU и heap, block/mutex включаются точечно при расследовании, execution trace — когда проблема в latency, а CPU свободен.
Глубже. Интервьюер хочет услышать не список, а связку «симптом → профиль». Хороший ответ строится так: перечислить профили (profile, heap/allocs, goroutine, block, mutex, trace), сказать, какой вопрос закрывает каждый, и привести один конкретный случай из практики: что болело, какой профиль сняли, что увидели в top/list, что поменяли и на сколько стало лучше в числах. Типичная ошибка — сказать «пользуюсь pprof» и замолчать; вторая — назвать heap-профиль средством поиска утечек горутин.
Как определить причину торможения приложения, если это скомпилированный бинарный файл и логи отсутствуют?
Заголовок раздела «Как определить причину торможения приложения, если это скомпилированный бинарный файл и логи отсутствуют?»Коротко. Сначала внешние наблюдения без модификации процесса: top/pidstat (CPU vs iowait), /proc/<pid>/status и smaps (RSS), ss/netstat (сокеты), strace -c -p <pid> (какие сисколлы доминируют). Дальше — kill -QUIT даёт полный дамп стеков всех горутин, а perf top -p <pid> / perf record -g показывает горячие функции прямо в бинаре, потому что Go сохраняет имена функций в pclntab даже при -s -w.
Глубже. Порядок действий, который хорошо звучит на собеседовании:
- Классифицировать: процесс упирается в CPU, в память/GC, в диск/сеть или просто спит?
top(%CPU vs %MEM),pidstat -d,vmstat,iostat. Если CPU близок к нулю, а latency большая — это ожидание, и CPU-профиль вам ничего не даст. - Снять стеки:
kill -QUIT <pid>(SIGQUIT) заставляет рантайм напечатать стеки всех горутин в stderr и завершить процесс; сGOTRACEBACK=all— стеки всех горутин, сGOTRACEBACK=crash— плюс core dump. Если процесс ронять нельзя —dlv attach <pid>иgoroutines -t, либо снять core черезgcoreи открытьdlv core. - Профиль без пересборки:
perf record -F 99 -g -p <pid> -- sleep 30— на amd64/arm64 Go по умолчанию сохраняет frame pointers, поэтому стеки разворачиваются корректно; символы берутся из бинаря. Дальшеperf reportили flame graph. Альтернатива — eBPF-профайлеры (Parca Agent, Pyroscope eBPF), они умеют профилировать Go-процессы без изменений в коде. - Если endpoint всё-таки есть: проверить, не открыт ли
/debug/pprof/— во многих сервисах он уже включён, просто про него забыли. - Пересобрать/перезапустить с диагностикой, если это допустимо:
GODEBUG=gctrace=1(что делает GC),GODEBUG=schedtrace=1000,scheddetail=1(очереди планировщика), добавить_ "net/http/pprof".
Отдельно стоит сказать: если бинарь собран другим человеком, нужен ровно тот же артефакт для символизации, иначе pprof/perf покажут адреса вместо имён.
Работал ли с профилировщиком в Go? Что делал? Какие результаты?
Заголовок раздела «Работал ли с профилировщиком в Go? Что делал? Какие результаты?»Коротко. Каркас: назвать конкретную задачу, симптом в числах, какой профиль сняли, какую строку кода он показал, что изменили, и итоговую дельту в тех же числах (p99 latency, RPS, RSS, allocs/op).
Глубже. Интервьюер проверяет не знание команд, а наличие законченного цикла «измерил → понял → починил → доказал». Сильный ответ звучит примерно так: «Сервис держал 4k RPS при p99 = 180 мс, CPU-профиль показал 40 % времени в encoding/json.(*decodeState).object и reflect; перевели горячий endpoint на кодогенерацию, добавили sync.Pool для буферов, allocs/op упал с 220 до 30, p99 стал 45 мс, CPU-утилизация — с 70 % до 35 %». Типичные ошибки: рассказ без чисел; рассказ про «переписали на горутины и стало быстрее» без профиля; путаница между inuse_space и alloc_space при описании находки.
Использовал pprof?
Заголовок раздела «Использовал pprof?»Коротко. Да — см. выше; коротко: net/http/pprof в сервисах, go test -cpuprofile/-memprofile локально, анализ через go tool pprof -http=:8080 (flame graph, top, list, peek), сравнение релизов через -diff_base.
Глубже. Отличие от предыдущего вопроса — здесь достаточно продемонстрировать беглость в инструменте. Полезно упомянуть три команды, которые реально используешь: top -cum (кумулятивное время — видно, какая подсистема в целом дорогая), list <regexp> (построчная раскладка внутри функции), web/-http (граф и flame graph). И одну редкую, которая показывает опыт: pprof -diff_base=old.pprof new.pprof для регрессий или granularity=lines для точного места.
Какими инструментами для оценки производительности и профилирования пользуетесь во время разработки?
Заголовок раздела «Какими инструментами для оценки производительности и профилирования пользуетесь во время разработки?»Коротко. go test -bench -benchmem + benchstat для микроизмерений, pprof (CPU/heap/block/mutex) для поиска горячих мест, go tool trace для latency и планировщика, go build -gcflags='-m' для escape-анализа, go vet/-race для корректности, PGO для «бесплатного» ускорения на релизе.
Глубже. Полный набор, который стоит упомянуть:
- Микроуровень:
testing.B,-benchtime=10x/10s,-count=10,benchstat(golang.org/x/perf/cmd/benchstat),-benchmem. - Профили:
runtime/pprof,net/http/pprof,go tool pprof(в т.ч.-httpс flame graph), профили прямо из бенчмарков (-cpuprofile,-memprofile,-blockprofile,-mutexprofile). - Трассировка:
runtime/trace+go tool trace, execution trace показывает GC-паузы, вытеснение, сисколлы, задержку планировщика. - Компилятор:
-gcflags='-m -m'(escape-анализ и инлайнинг),-gcflags=-Sиgo tool objdump(ассемблер),-gcflags=-lдля отключения инлайна в экспериментах,-cover/-raceотдельно. - Рантайм-наблюдаемость: пакет
runtime/metrics(правильный способ снимать метрики рантайма, в отличие от устаревшегоruntime.ReadMemStats, который делает STW),GODEBUG=gctrace=1,GOMEMLIMIT/GOGC. - Прод: continuous profiling (Pyroscope/Parca/Datadog), Prometheus + Grafana, распределённый трейсинг (OpenTelemetry, Jaeger).
- Системные:
perf,bpftrace,strace,wrk/vegeta/k6для генерации нагрузки.
Как ускорить весь метод?
Заголовок раздела «Как ускорить весь метод?»Коротко. Сначала измерить: написать бенчмарк на этот метод и снять CPU- и mem-профиль; дальше по убыванию эффекта — убрать лишнюю работу (кэш, ранний выход, батчинг), убрать аллокации (переиспользование буферов, make с capacity, отказ от интерфейсов и рефлексии в горячем пути), убрать блокировки, и только в конце — микрооптимизации и параллелизм.
Глубже. Практический чеклист «ускорения метода» в Go, примерно в порядке отдачи:
- Алгоритм и объём работы: O(n²) → O(n log n), лишние проходы, повторные вычисления в цикле,
strings.Containsвместо предварительно скомпилированного автомата, regexp внутри цикла вместоMustCompileв переменной пакета. - Аллокации:
-benchmemпокажетallocs/op. Типичные победы:make([]T, 0, n)вместо роста слайса,strings.Builderвместо конкатенации,sync.Poolдля крупных временных буферов, приёмbuf []byteпараметром и возвратappend(buf, ...), отказ отinterface{}-боксинга,bytesвместоstringтам, где идёт конвертация туда-обратно. - Escape-анализ:
go build -gcflags='-m'покажетescapes to heap. Часто хватает не возвращать указатель на локальную структуру или не передавать её вinterface{}/в замыкание. - Инлайнинг: маленькие функции инлайнятся, если укладываются в бюджет стоимости;
-gcflags='-m'печатаетcan inline/cannot inline: function too complex. Разбить большую функцию так, чтобы горячий короткий путь инлайнился, а холодный ушёл в отдельную noinline-функцию — рабочий приём. - Синхронизация: mutex-профиль; заменить
sync.Mutexнаsync.RWMutex(не всегда быстрее), шардировать мапу, взятьatomic, убрать общий счётчик из горячего пути. - Параллелизм: имеет смысл только если работа реально делится и метод CPU-bound;
b.RunParallelдля проверки масштабирования. - Микро: избежать bounds check (подсказка
_ = s[n-1]перед циклом), выровнять структуры, уменьшить их размер, использовать таблицы вместо ветвлений.
И главное — после каждого шага мерить benchstat, потому что интуиция про производительность в Go ошибается регулярно.
Есть ли опыт работы с pprof? Для чего он нужен?
Заголовок раздела «Есть ли опыт работы с pprof? Для чего он нужен?»Коротко. pprof нужен, чтобы получить распределение ресурса по стекам вызовов: сколько CPU-времени, байт, событий блокировки приходится на каждую функцию и строку. Он отвечает на вопрос «где именно тратится», а не «сколько всего тратится».
Глубже. Два компонента, которые часто путают: пакеты runtime/pprof / net/http/pprof собирают данные, а go tool pprof (форк github.com/google/pprof) их отображает. Инструмент умеет читать профиль из файла, из URL и из stdin, поддерживает форматы вывода top, tree, list, disasm, web (SVG-граф), -http=:8080 (интерактив: Graph, Flame Graph, Peek, Source), а также -base/-diff_base для сравнения двух профилей. Профиль многомерный: в heap-профиле четыре значения — alloc_objects, alloc_space, inuse_objects, inuse_space, и переключение между ними (-sample_index=inuse_space) меняет смысл картинки. Ещё одна полезная возможность — pprof labels (pprof.Do, pprof.Labels): CPU- и goroutine-профили можно разрезать по тегам (endpoint, tenant, тип задачи) через -tagfocus.
Приложение начало потреблять 100% CPU. Как определить проблему и что предпринять?
Заголовок раздела «Приложение начало потреблять 100% CPU. Как определить проблему и что предпринять?»Коротко. Снять CPU-профиль за 30 секунд (go tool pprof http://host:6060/debug/pprof/profile?seconds=30), посмотреть top -cum и flame graph. Параллельно проверить, не GC ли это (GODEBUG=gctrace=1 или метрика /gc/cpu/... из runtime/metrics) и не выросло ли число горутин.
Глубже. Типичные причины и как их различить:
- Реальная нагрузка выросла — профиль равномерно размазан по бизнес-логике, RPS вырос. Не проблема кода.
- GC съедает CPU — в профиле сверху
runtime.gcBgMarkWorker,runtime.scanobject,runtime.mallocgc. Значит, проблема в аллокациях: смотретьallocs-профиль, снижать мусор, при необходимости поднятьGOGCили задатьGOMEMLIMIT. - Busy loop / spin — в профиле одна функция без сисколлов, часто
for {}безselect, ожидание флага безruntime.Gosched, илиsync.Mutexв состоянии активной раскрутки (sync.(*Mutex).lockSlow,runtime.procyield). До Go 1.14 бесконечный цикл без вызовов функций мог вообще заблокировать GC; с 1.14 есть асинхронная вытесняемость, но CPU он всё равно жжёт. - Contention — много
runtime.futex,runtime.lock2,sync.runtime_SemacquireMutex. Включить mutex-профиль. - Регулярка / JSON / криптография в горячем пути — сразу видно по именам
regexp.(*machine).match,encoding/json,crypto/tls. - cgo — время уходит в
runtime.cgocall, а внутрь Go-профилировщик не видит; нуженperf.
Что предпринять немедленно: снять профиль (он нужен даже если вы уже перезапустите), сравнить с профилем прошлого релиза через -diff_base, при инциденте — откатить релиз/включить фичефлаг, ограничить конкурентность, добавить rate limit. Потом чинить по профилю.
Приложение начало потреблять 100% RAM. Как понять, что происходит?
Заголовок раздела «Приложение начало потреблять 100% RAM. Как понять, что происходит?»Коротко. Снять heap-профиль (/debug/pprof/heap, метрика inuse_space) и посмотреть top; параллельно проверить /debug/pprof/goroutine?debug=1 — очень часто «утечка памяти» в Go это утечка горутин, каждая из которых держит стек и замыкания.
Глубже. Разделяйте три разных явления:
- Живой heap реально растёт —
inuse_spaceпоказывает, кто держит память: глобальный кэш без вытеснения, мапа, из которой не удаляют, слайс, растущий вечно. Здесь помогает снять два heap-профиля с интервалом и сравнить:go tool pprof -base=heap1.pb.gz heap2.pb.gz. - Утечка горутин — число горутин растёт линейно со временем; в goroutine-профиле видно один и тот же стек в десятках тысяч экземпляров, обычно
chan receive,select,sync.WaitGroup.Wait, отсутствиеctxили незакрытыйresp.Body. Heap-профиль тут покажет только косвенные симптомы. - RSS большой, а heap маленький — рантайм вернул страницы ядру лениво. На Linux Go с 1.16 по умолчанию использует
MADV_DONTNEED, так что RSS обычно падает, но не мгновенно; ещё RSS раздувают стеки горутин, mmap’нутые файлы, cgo-аллокации (их в heap-профиле нет вообще — смотреть/proc/<pid>/smaps, jemalloc-профили или valgrind).
Полезные вещи: runtime/metrics (/memory/classes/*) даёт разбивку памяти по классам гораздо точнее, чем MemStats; GODEBUG=gctrace=1 показывает, растёт ли цель GC; GOMEMLIMIT (Go 1.19+) задаёт мягкий лимит, при котором GC начинает работать чаще, — это способ выжить в контейнере, но не лечение утечки. Классические источники: незакрытые http.Response.Body, time.Ticker без Stop(), подписки без отписки, sync.Pool с гигантскими объектами, суб-слайсы, удерживающие огромный базовый массив, context без отмены.
Как вы профилировали Go-программы? Какие инструменты использовали? Какие виды профилирования?
Заголовок раздела «Как вы профилировали Go-программы? Какие инструменты использовали? Какие виды профилирования?»Коротко. Виды: CPU, heap (inuse/alloc), goroutine, block, mutex, threadcreate, плюс execution trace как отдельный механизм. Инструменты: runtime/pprof, net/http/pprof, go test -cpuprofile/-memprofile, go tool pprof, go tool trace, benchstat, continuous profiling в проде.
Глубже. Таблица соответствия, которую полезно проговорить вслух:
| Профиль | Что измеряет | Как включить |
|---|---|---|
profile (CPU) | процессорное время по стекам, сэмплы 100 Гц | всегда доступен, ?seconds=N |
heap | живые объекты (inuse_space/objects) | всегда, сэмплинг MemProfileRate = 512 КБ |
allocs | все аллокации с начала процесса | те же данные, другой sample_index |
goroutine | стеки всех горутин (не сэмплинг) | всегда; ?debug=2 — текстовый дамп |
block | время ожидания на каналах/семафорах | runtime.SetBlockProfileRate |
mutex | контеншен на sync.Mutex/RWMutex | runtime.SetMutexProfileFraction |
threadcreate | создание потоков ОС | всегда, но исторически ненадёжен |
trace | полная хронология событий рантайма | runtime/trace, ?seconds=N |
Важно подчеркнуть, что CPU- и heap-профиль отвечают на разные вопросы и что block/mutex по умолчанию выключены — на собеседовании это частая проверка.
Есть ли у вас опыт работы с профилировщиком?
Заголовок раздела «Есть ли у вас опыт работы с профилировщиком?»Коротко. См. выше про pprof; отличие этого вопроса в том, что он открытый и его задают, чтобы понять глубину, — отвечать надо не «да», а коротким кейсом с числами.
Глубже. Минимальный сильный ответ на 30 секунд: «Да. В сервисе X был рост p99; CPU-профиль показал, что 30 % времени уходит на regexp внутри middleware — компилировали регулярку на каждый запрос. Вынесли в пакетную переменную, p99 упал на треть. Профили снимаю через net/http/pprof на служебном порту, смотрю flame graph в go tool pprof -http». Дальше интервьюер сам углубится — в overhead, в виды профилей или в trace.
Как вы выявляете и устраняете узкие места, связанные с планировщиком, в высоконагруженных приложениях?
Заголовок раздела «Как вы выявляете и устраняете узкие места, связанные с планировщиком, в высоконагруженных приложениях?»Коротко. Планировочные проблемы видны не в pprof, а в go tool trace: разделы Scheduler latency profile и Goroutine analysis показывают, сколько времени горутины провели в состоянии «готова, но не выполняется». Дополнительно — GODEBUG=schedtrace=1000 (длины очередей, число P/M) и block/mutex-профили.
Глубже. Что конкретно ищем и чем лечим:
- GOMAXPROCS не соответствует cgroup-лимиту. Классика в Kubernetes: контейнеру дали 2 CPU, а
GOMAXPROCS= числу ядер ноды (например, 64), из-за чего рантайм плодит P, растёт контеншен и throttling. Лечитсяuber-go/automaxprocsили явнымGOMAXPROCS; в новых версиях Go рантайм начал учитывать cgroup-лимит сам, но полагаться на это стоит только после проверки версии. - Блокирующие сисколлы: горутина в сисколле отдаёт P другому M, но при массовых блокирующих операциях (файловый ввод-вывод, cgo,
os/exec) число потоков растёт до лимита 10000, растут переключения контекста. Лечение — ограничить конкурентность пулом, использовать неблокирующий ввод-вывод через netpoller, батчить. - Длинные несжимаемые куски работы. До Go 1.14 цикл без вызовов функций не вытеснялся вовсе; сейчас есть асинхронная вытесняемость через сигналы, но длинный участок всё равно задерживает соседей и STW-фазы GC. Дробить работу, добавлять точки отмены.
- Дисбаланс очередей и work stealing: в трейсе видно, что часть P простаивает, а на одной длинная очередь. Обычно причина — генерация задач из одной горутины и слишком мелкие задачи; помогает батчинг и локальные буферы.
- Contention:
sync.Mutexпод нагрузкой уходит в starvation mode (после 1 мс ожидания), пропускная способность падает. Смотреть mutex-профиль, шардировать состояние, заменять на atomic/sync.Mapтам, где паттерн доступа подходит. - GC assist: в трейсе видно
MARK ASSISTна горутинах-аллокаторах — приложение платит за собственный мусор. Лечится снижением аллокаций, не тюнингом планировщика.
Полезно упомянуть, что с Go 1.22 трассировщик переписан: overhead упал до порядка процента, трейсы стали разбиваться на независимые куски, а значит их реально снимать в проде короткими окнами.
Использовали ли вы трассировку выполнения?
Заголовок раздела «Использовали ли вы трассировку выполнения?»Коротко. Да, runtime/trace + go tool trace. Она нужна, когда CPU-профиль ничего не объясняет: показывает по времени, что делала каждая горутина, где были GC-паузы, сисколлы, ожидание планировщика и сетевые блокировки.
Глубже. Включение — либо в коде (trace.Start(f) / trace.Stop()), либо через /debug/pprof/trace?seconds=5, либо go test -trace=trace.out. Смотреть: go tool trace trace.out откроет браузер с представлениями — временная шкала по процессорам, «Goroutine analysis» (разбивка времени горутины на execution / scheduler wait / syscall / GC / network wait), «Network blocking profile», «Synchronization blocking profile», «Scheduler latency profile». Свои интервалы и регионы размечаются через trace.NewTask, trace.WithRegion, trace.Log — это очень помогает связать трейс с бизнес-этапами запроса.
Ограничения: трейс огромный (десятки МБ за секунды), поэтому снимают короткими окнами; до Go 1.22 overhead был заметный (единицы-десятки процентов), после переписывания трассировщика стал существенно меньше. Для «поймать момент инцидента» есть flight recorder — кольцевой буфер трейса, который сбрасывается по событию (сначала в golang.org/x/exp/trace, позже перенесён в стандартную библиотеку); если сомневаетесь в версии, честнее сказать «в экспериментальном пакете x/exp/trace».
package main
import ( "os" "runtime/trace")
func main() { f, err := os.Create("trace.out") if err != nil { panic(err) } defer f.Close()
if err := trace.Start(f); err != nil { panic(err) } defer trace.Stop()
work()}
func work() { /* полезная нагрузка */ }Есть сошное приложение, которое тормозит. Что делать?
Заголовок раздела «Есть сошное приложение, которое тормозит. Что делать?»Коротко. (Скорее всего в расшифровке «Go-шное».) Сначала определить характер тормозов: упирается в CPU, в память/GC или в ожидание. Если CPU высокий — CPU-профиль; если CPU низкий, а latency высокая — block/mutex-профиль и execution trace; если растёт RSS — heap и goroutine.
Глубже. Рабочая последовательность: (1) метрики — RPS, p50/p99, CPU, RSS, число горутин, GC pause; (2) убедиться, что тормозит именно приложение, а не БД/внешний сервис — распределённый трейсинг или хотя бы тайминги вокруг вызовов; (3) снять профили с проблемного инстанса; (4) сравнить с профилем здорового инстанса или предыдущего релиза (-diff_base) — это резко ускоряет поиск; (5) сформулировать гипотезу, проверить бенчмарком, починить, проверить в канарейке. Важный акцент для интервью: чаще всего «Go-приложение тормозит» оказывается не Go-проблемой — N+1 к базе, отсутствие пула соединений, синхронный вызов внешнего API без таймаута, http.Client по умолчанию без ограничения MaxIdleConnsPerHost. Профиль это тоже покажет — но как ожидание, а не как CPU.
Что можно отладить при помощи pprof?
Заголовок раздела «Что можно отладить при помощи pprof?»Коротко. Высокое потребление CPU, избыточные аллокации и рост памяти, утечки горутин, контеншен на мьютексах и долгие блокировки на каналах, а также распределение ресурсов по бизнес-меткам через pprof labels.
Глубже. Чего pprof не покажет — тоже хороший ответ: он не покажет wall-clock latency конкретного запроса (для этого трейсинг), порядок событий во времени (для этого go tool trace), происходящее внутри cgo и в ядре (для этого perf), а также память, выделенную не через Go-аллокатор (mmap, cgo, стеки потоков). И ещё: pprof — не отладчик, точки останова и значения переменных — это delve.
Можешь, пожалуйста, рассказать именно по гошным каким-то задачам? Может, были очень какие-то интересные задачи? Особенно интересуют вообще, какие нагрузки были на сервисы именно гошные, и может, какие-то оптимизации приходилось делать, профилированием заниматься в таком плане?
Заголовок раздела «Можешь, пожалуйста, рассказать именно по гошным каким-то задачам? Может, были очень какие-то интересные задачи? Особенно интересуют вообще, какие нагрузки были на сервисы именно гошные, и может, какие-то оптимизации приходилось делать, профилированием заниматься в таком плане?»Коротко. Это вопрос про опыт. Каркас ответа: масштаб в числах (RPS, объём данных, размер кластера, SLA) → одна-две конкретные интересные задачи → что именно было сложно → как измеряли и что улучшили.
Глубже. Что хочет услышать интервьюер:
- Цифры нагрузки, чтобы откалибровать ваш опыт: «12 инстансов, суммарно 30k RPS, p99 SLA 100 мс, поток событий 200 МБ/мин в Kafka». Без цифр рассказ звучит неубедительно.
- Одна история целиком, а не пять по верхам. Структура: контекст → симптом → гипотезы → измерение → решение → результат → что бы сделали иначе.
- Технические детали, специфичные для Go: где именно помогли
sync.Pool, шардирование мапы, заменаencoding/json,GOMEMLIMITв контейнере,automaxprocs, батчинг записей, отказ от горутины на элемент в пользу воркер-пула. - Честность про пределы: «профиль показал, что дальше упираемся в TLS-handshake, это уже вне нашего кода — вынесли терминацию на балансировщик».
Типичные ошибки: обобщённые формулировки («у нас были высокие нагрузки»), приписывание себе командных результатов, оптимизации без измерений, и рассказ про «переписали с Python на Go, стало в 10 раз быстрее» — это про язык, а не про вашу инженерную работу.
Что делает pprof? Rак собирать метрики с гошного приложения через pprof?
Заголовок раздела «Что делает pprof? Rак собирать метрики с гошного приложения через pprof?»Коротко. pprof собирает сэмплы «стек вызовов + значение» и агрегирует их в профиль. Собрать проще всего так: импортировать _ "net/http/pprof", поднять служебный HTTP-сервер и дёргать go tool pprof http://host:6060/debug/pprof/{profile,heap,goroutine,block,mutex}; либо писать профили в файл из кода через runtime/pprof.
Глубже. Набор endpoint’ов, которые регистрирует net/http/pprof: /debug/pprof/ (индекс), profile?seconds=30 (CPU), heap, allocs, goroutine (+?debug=1|2), block, mutex, threadcreate, trace?seconds=5, cmdline, symbol. Типовые команды:
# CPU-профиль 30 секунд и сразу интерактивный веб-интерфейсgo tool pprof -http=:8080 http://localhost:6060/debug/pprof/profile?seconds=30
# Живая памятьgo tool pprof -sample_index=inuse_space http://localhost:6060/debug/pprof/heap
# Текстовый дамп всех горутин прямо в терминалcurl -s http://localhost:6060/debug/pprof/goroutine?debug=2 | head -100
# Сравнение двух профилей (регрессия между релизами)go tool pprof -diff_base=v1.pprof ./app v2.pprofВажная оговорка про «метрики»: pprof — это не система метрик. Числовые метрики рантайма (число горутин, объём heap, паузы GC, CPU-доля GC) правильнее снимать пакетом runtime/metrics и отдавать в Prometheus; pprof же даёт профиль, который либо смотрят руками, либо складывают в continuous-profiling-хранилище.
Знакомы с Benchmarks?
Заголовок раздела «Знакомы с Benchmarks?»Коротко. Да: функции func BenchmarkXxx(b *testing.B) в _test.go, запуск go test -bench=. -benchmem, рантайм сам подбирает b.N. Ключевые метрики — ns/op, B/op, allocs/op; сравнивать серии прогонов нужно через benchstat.
Глубже. Что важно знать, чтобы бенчмарк не врал:
package bench
import ( "encoding/json" "testing")
var sink []byte
func BenchmarkMarshal(b *testing.B) { payload := makePayload() b.ReportAllocs() b.ResetTimer() // не учитывать подготовку данных
for i := 0; i < b.N; i++ { out, err := json.Marshal(payload) if err != nil { b.Fatal(err) } sink = out // защита от устранения мёртвого кода }}- Dead code elimination: если результат никуда не идёт, компилятор может выкинуть вычисление. Классическое решение — присваивание в пакетную переменную (
sink). В Go 1.24 добавилиfor b.Loop() { ... }: такой цикл сам управляет таймером, гарантирует, что аргументы и результат не будут оптимизированы прочь, и выполняет тело нужное число раз — новый рекомендуемый способ. - Подготовка внутри цикла искажает результат: выносить наружу, использовать
b.ResetTimer(),b.StopTimer()/b.StartTimer(). - Шум: одно измерение ничего не значит.
go test -bench=X -count=10 -benchtime=1s, затемbenchstat old.txt new.txt, смотреть на p-value и разброс. Отключить турбобуст/энергосбережение, не гонять бенчмарки на ноутбуке под нагрузкой и в CI-контейнере со случайным соседом. - Параллельные бенчмарки:
b.RunParallel(func(pb *testing.PB) { for pb.Next() { ... } })— проверяет масштабирование и вылавливает контеншен. - Профили из бенчмарка:
go test -bench=X -cpuprofile=cpu.out -memprofile=mem.out, затемgo tool pprof. Помнить, что бинарь теста остаётся (-o bench.test) и нужен для символизации. - Микробенчмарк ≠ прод: он не воспроизводит кэш-промахи, GC-давление и конкуренцию. Ускорение в 3 раза в бенчмарке может дать 0 % в сервисе.
Как встроить стандартный профайлер в свое приложение?
Заголовок раздела «Как встроить стандартный профайлер в свое приложение?»Коротко. Для сервиса — импортировать _ "net/http/pprof" и поднять HTTP-сервер (лучше на отдельном служебном порту, не на публичном). Для CLI/батча — писать профиль в файл через runtime/pprof.
Глубже. Вариант для сервиса, безопасный (свой mux, отдельный listener, никакого DefaultServeMux):
package main
import ( "log" "net/http" "net/http/pprof" "runtime")
func debugServer(addr string) { 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)
// block и mutex по умолчанию выключены runtime.SetBlockProfileRate(10_000) // ~1 сэмпл на каждые 10 мкс блокировки runtime.SetMutexProfileFraction(100) // 1 событие контеншена из 100
log.Println(http.ListenAndServe(addr, mux))}
func main() { go debugServer("127.0.0.1:6060") runService()}
func runService() { /* ... */ }Вариант для CLI (профиль в файл):
package main
import ( "os" "runtime" "runtime/pprof")
func main() { cpu, err := os.Create("cpu.pprof") if err != nil { panic(err) } defer cpu.Close() if err := pprof.StartCPUProfile(cpu); err != nil { panic(err) } defer pprof.StopCPUProfile()
work()
mem, err := os.Create("heap.pprof") if err != nil { panic(err) } defer mem.Close() runtime.GC() // чтобы в профиле были актуальные живые объекты if err := pprof.WriteHeapProfile(mem); err != nil { panic(err) }}
func work() { /* ... */ }Полезные добавки: pprof-labels, чтобы резать профиль по типу запроса —
pprof.Do(ctx, pprof.Labels("handler", "checkout"), func(ctx context.Context) { handleCheckout(ctx)})Про безопасность стоит сказать явно: import _ "net/http/pprof" регистрируется в http.DefaultServeMux, и если ваш публичный сервер использует nil в качестве handler’а, endpoint окажется в интернете. Это и утечка информации (cmdline, имена функций), и вектор DoS (?seconds=3600).
Overhead от стандартного профайлера?
Заголовок раздела «Overhead от стандартного профайлера?»Коротко. У профилей по умолчанию он небольшой: CPU-профилирование обычно единицы процентов, heap-профилирование при стандартном MemProfileRate — около процента. Опасны именно те, что выключены по умолчанию: SetBlockProfileRate(1) и SetMutexProfileFraction(1) могут стоить очень дорого, а goroutine-профиль на большом числе горутин делает заметную паузу.
Глубже. По механизмам:
- CPU:
SetCPUProfileRateпо умолчанию 100 Гц; сбор идёт по сигналу SIGPROF, обработчик разворачивает стек. На Linux с Go 1.18 используются таймеры на поток, что и точнее, и убирает прежнюю проблему с недосэмплированием на многоядерных машинах. Замеры сообщества дают порядка 1–5 % деградации throughput; сильнее страдают приложения с очень глубокими стеками. - Heap: сэмплирование — в среднем один сэмпл на каждые
runtime.MemProfileRate= 512 КБ выделенной памяти; на аллокации накладываются считанные наносекунды. СтавитьMemProfileRate = 1(профилировать каждую аллокацию) можно только локально — это очень дорого. Ставить в 0 — отключить. - Block:
SetBlockProfileRate(rate)— rate в наносекундах; событие блокировки записывается с вероятностью, пропорциональной его длительности, так чтоrate=1означает «записывать всё» и в высококонкурентном коде может обвалить производительность в разы. Разумное значение для прода — 10 000–1 000 000 нс. - Mutex:
SetMutexProfileFraction(n)— регистрируется 1 событие контеншена из n.n=1дорого,n=100практически бесплатно. - Goroutine: не сэмплирующий — рантайм должен обойти все горутины. Раньше это была полная STW-пауза, пропорциональная их числу (на 1M горутин — сотни миллисекунд); в Go 1.19 механизм переделали так, что STW короткий, а развёртывание стеков идёт параллельно, но стоимость всё равно O(N). Не дёргать этот endpoint раз в секунду.
- Execution trace: до Go 1.22 — заметные проценты (по разным замерам до десятков процентов на «болтливых» нагрузках), после переписывания трассировщика — существенно дешевле, порядка процента, и трейс можно снимать окнами в проде.
Вывод для собеседования: CPU + heap безопасно держать включёнными постоянно (и на этом построены continuous-profiling-системы), block/mutex — включать с большим rate, goroutine и trace — по требованию.
Если сериализация медленная, то что делать?
Заголовок раздела «Если сериализация медленная, то что делать?»Коротко. Сначала подтвердить профилем, что горячее место — именно сериализация (обычно видно encoding/json и reflect). Дальше по возрастанию радикальности: убрать лишнюю сериализацию, снизить аллокации и переиспользовать буферы, перейти на кодогенерацию (easyjson, protobuf) или на более быструю библиотеку, в пределе — сменить формат на бинарный.
Глубже. Конкретные шаги:
- Не сериализовать лишнего. Самая большая победа — не делать работу: кэшировать готовый JSON, отдавать
json.RawMessageдля полей, которые проходят насквозь без изменений, отрезать неиспользуемые поля черезjson:"-", не логировать целые структуры в JSON на каждый запрос. - Стриминг вместо буферизации.
json.NewEncoder(w).Encode(v)пишет прямо вhttp.ResponseWriter, не создавая промежуточный[]byte;json.NewDecoder(r.Body).Decode(&v)не требует читать всё тело в память. - Аллокации. Переиспользовать буферы через
sync.Pool, задавать capacity, избегатьmap[string]interface{}(это боксинг и рефлексия на каждое поле) в пользу типизированных структур, избегать конвертаций[]byte↔stringв горячем пути. - Убрать рефлексию.
encoding/jsonстроит и кэширует описание типа, но всё равно ходит черезreflect. Кодогенерация (easyjson,ffjson) генерирует прямые методыMarshalJSON/UnmarshalJSONи даёт кратный выигрыш. Альтернативы-библиотеки:github.com/goccy/go-json,github.com/json-iterator/go,github.com/bytedance/sonic(JIT, только amd64/arm64) — все с оговоркой про совместимость и надёжность на краевых случаях. В Go идёт работа над новой реализациейencoding/json/v2, доступной под GOEXPERIMENT; в проде на неё стоит смотреть после стабилизации. - Сменить формат. Если обе стороны ваши — protobuf, msgpack, CBOR, FlatBuffers/Cap’n Proto (последние дают доступ без десериализации вообще). Для колоночных данных — Arrow/Parquet.
- Сжатие и сеть. Иногда «медленная сериализация» на самом деле медленная передача: gzip на каждый ответ может стоить дороже, чем экономит; проверять профилем.
- Измерить. Каждый шаг подтверждать бенчмарком с
-benchmemиbenchstat: разница между библиотеками сильно зависит от формы данных (много мелких полей vs крупные строки vs массивы чисел).
Работали ли вы с профайлингом и что это такое?
Заголовок раздела «Работали ли вы с профайлингом и что это такое?»Коротко. См. первый вопрос: профилирование — сбор статистики о потреблении ресурсов с привязкой к стекам вызовов. Отличие этого вопроса в том, что он комбинированный: сначала дать определение в одно предложение, потом сразу приземлить его на личный опыт с конкретным примером.
Глубже. Схема ответа на 40–60 секунд: (1) определение и зачем — «найти узкое место фактически, а не по интуиции»; (2) чем в Go — runtime/pprof, net/http/pprof, go tool pprof, go tool trace, бенчмарки; (3) какие профили и какой вопрос закрывает каждый; (4) один короткий кейс с числами; (5) оговорка про overhead и безопасность endpoint’а в проде. Такой ответ закрывает сразу и «что это», и «есть ли опыт», и обычно снимает половину последующих уточняющих вопросов.
Частые ошибки на собесе
Заголовок раздела «Частые ошибки на собесе»- Считать, что pprof — это один инструмент про CPU. На вопрос «приложение тормозит, но CPU 5 %» отвечать CPU-профилем — провал; нужны block/mutex-профиль и
go tool trace. - Путать
heapиallocs(inuse_spacevsalloc_space): для поиска утечки нуженinuse_space, для снижения давления на GC —alloc_space. - Искать утечку горутин в heap-профиле. Утечка горутин видна в
/debug/pprof/goroutine, а число горутин — в метриках. - Не знать, что
blockиmutexпрофили по умолчанию выключены, и удивляться пустому профилю. - Забывать, что
import _ "net/http/pprof"вешает endpoint’ы наhttp.DefaultServeMux, и оставлять их доступными снаружи. - Говорить «оптимизировал», не приводя способ измерения. Отсутствие бенчмарка/профиля до и после — красный флаг.
- Писать бенчмарк с подготовкой данных внутри цикла и без защиты результата от устранения мёртвого кода, а потом делать выводы по одному прогону без
benchstat. - Считать, что рост RSS всегда означает утечку в Go-heap: cgo, стеки горутин и ленивый возврат страниц ядру в heap-профиле не видны.
- Утверждать, что профилирование в проде нельзя включать вообще. CPU и heap с дефолтными настройками стоят единицы процентов, на этом построены continuous-profiling-системы.
Что почитать
Заголовок раздела «Что почитать»- Profiling Go Programs — Go Blog — канонический разбор поиска узкого места с pprof.
- Documentation:
runtime/pprofиnet/http/pprof— какие профили есть, как их включать, семантикаSetBlockProfileRateиSetMutexProfileFraction. - Diagnostics — go.dev — обзорная страница: профилирование, трассировка, отладка,
GODEBUG. - Profile-guided optimization — go.dev — как использовать профиль из прода для ускорения сборки.
- google/pprof — README и UI — команды
top,list,peek,-diff_base, flame graph. - The Busy Developer’s Guide to Go Profiling, Tracing and Observability (Felix Geisendörfer) — подробные замеры overhead всех профилей.