Перейти к содержимому

Профилирование и производительность

Профилирование в 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 это встроено в рантайм: 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 часа ночи», когда инцидент уже прошёл.

Коротко. Каркас ответа: да, регулярно; по умолчанию во всех сервисах включён 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.

Глубже. Порядок действий, который хорошо звучит на собеседовании:

  1. Классифицировать: процесс упирается в CPU, в память/GC, в диск/сеть или просто спит? top (%CPU vs %MEM), pidstat -d, vmstat, iostat. Если CPU близок к нулю, а latency большая — это ожидание, и CPU-профиль вам ничего не даст.
  2. Снять стеки: kill -QUIT <pid> (SIGQUIT) заставляет рантайм напечатать стеки всех горутин в stderr и завершить процесс; с GOTRACEBACK=all — стеки всех горутин, с GOTRACEBACK=crash — плюс core dump. Если процесс ронять нельзя — dlv attach <pid> и goroutines -t, либо снять core через gcore и открыть dlv core.
  3. Профиль без пересборки: perf record -F 99 -g -p <pid> -- sleep 30 — на amd64/arm64 Go по умолчанию сохраняет frame pointers, поэтому стеки разворачиваются корректно; символы берутся из бинаря. Дальше perf report или flame graph. Альтернатива — eBPF-профайлеры (Parca Agent, Pyroscope eBPF), они умеют профилировать Go-процессы без изменений в коде.
  4. Если endpoint всё-таки есть: проверить, не открыт ли /debug/pprof/ — во многих сервисах он уже включён, просто про него забыли.
  5. Пересобрать/перезапустить с диагностикой, если это допустимо: 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 при описании находки.

Коротко. Да — см. выше; коротко: 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, примерно в порядке отдачи:

  1. Алгоритм и объём работы: O(n²) → O(n log n), лишние проходы, повторные вычисления в цикле, strings.Contains вместо предварительно скомпилированного автомата, regexp внутри цикла вместо MustCompile в переменной пакета.
  2. Аллокации: -benchmem покажет allocs/op. Типичные победы: make([]T, 0, n) вместо роста слайса, strings.Builder вместо конкатенации, sync.Pool для крупных временных буферов, приём buf []byte параметром и возврат append(buf, ...), отказ от interface{}-боксинга, bytes вместо string там, где идёт конвертация туда-обратно.
  3. Escape-анализ: go build -gcflags='-m' покажет escapes to heap. Часто хватает не возвращать указатель на локальную структуру или не передавать её в interface{}/в замыкание.
  4. Инлайнинг: маленькие функции инлайнятся, если укладываются в бюджет стоимости; -gcflags='-m' печатает can inline / cannot inline: function too complex. Разбить большую функцию так, чтобы горячий короткий путь инлайнился, а холодный ушёл в отдельную noinline-функцию — рабочий приём.
  5. Синхронизация: mutex-профиль; заменить sync.Mutex на sync.RWMutex (не всегда быстрее), шардировать мапу, взять atomic, убрать общий счётчик из горячего пути.
  6. Параллелизм: имеет смысл только если работа реально делится и метод CPU-bound; b.RunParallel для проверки масштабирования.
  7. Микро: избежать 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 это утечка горутин, каждая из которых держит стек и замыкания.

Глубже. Разделяйте три разных явления:

  1. Живой heap реально растётinuse_space показывает, кто держит память: глобальный кэш без вытеснения, мапа, из которой не удаляют, слайс, растущий вечно. Здесь помогает снять два heap-профиля с интервалом и сравнить: go tool pprof -base=heap1.pb.gz heap2.pb.gz.
  2. Утечка горутин — число горутин растёт линейно со временем; в goroutine-профиле видно один и тот же стек в десятках тысяч экземпляров, обычно chan receive, select, sync.WaitGroup.Wait, отсутствие ctx или незакрытый resp.Body. Heap-профиль тут покажет только косвенные симптомы.
  3. 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/RWMutexruntime.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.

Коротко. Высокое потребление CPU, избыточные аллокации и рост памяти, утечки горутин, контеншен на мьютексах и долгие блокировки на каналах, а также распределение ресурсов по бизнес-меткам через pprof labels.

Глубже. Чего pprof не покажет — тоже хороший ответ: он не покажет wall-clock latency конкретного запроса (для этого трейсинг), порядок событий во времени (для этого go tool trace), происходящее внутри cgo и в ядре (для этого perf), а также память, выделенную не через Go-аллокатор (mmap, cgo, стеки потоков). И ещё: pprof — не отладчик, точки останова и значения переменных — это delve.

Можешь, пожалуйста, рассказать именно по гошным каким-то задачам? Может, были очень какие-то интересные задачи? Особенно интересуют вообще, какие нагрузки были на сервисы именно гошные, и может, какие-то оптимизации приходилось делать, профилированием заниматься в таком плане?

Заголовок раздела «Можешь, пожалуйста, рассказать именно по гошным каким-то задачам? Может, были очень какие-то интересные задачи? Особенно интересуют вообще, какие нагрузки были на сервисы именно гошные, и может, какие-то оптимизации приходилось делать, профилированием заниматься в таком плане?»

Коротко. Это вопрос про опыт. Каркас ответа: масштаб в числах (RPS, объём данных, размер кластера, SLA) → одна-две конкретные интересные задачи → что именно было сложно → как измеряли и что улучшили.

Глубже. Что хочет услышать интервьюер:

  1. Цифры нагрузки, чтобы откалибровать ваш опыт: «12 инстансов, суммарно 30k RPS, p99 SLA 100 мс, поток событий 200 МБ/мин в Kafka». Без цифр рассказ звучит неубедительно.
  2. Одна история целиком, а не пять по верхам. Структура: контекст → симптом → гипотезы → измерение → решение → результат → что бы сделали иначе.
  3. Технические детали, специфичные для Go: где именно помогли sync.Pool, шардирование мапы, замена encoding/json, GOMEMLIMIT в контейнере, automaxprocs, батчинг записей, отказ от горутины на элемент в пользу воркер-пула.
  4. Честность про пределы: «профиль показал, что дальше упираемся в 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-хранилище.

Коротко. Да: функции 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).

Коротко. У профилей по умолчанию он небольшой: 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) или на более быструю библиотеку, в пределе — сменить формат на бинарный.

Глубже. Конкретные шаги:

  1. Не сериализовать лишнего. Самая большая победа — не делать работу: кэшировать готовый JSON, отдавать json.RawMessage для полей, которые проходят насквозь без изменений, отрезать неиспользуемые поля через json:"-", не логировать целые структуры в JSON на каждый запрос.
  2. Стриминг вместо буферизации. json.NewEncoder(w).Encode(v) пишет прямо в http.ResponseWriter, не создавая промежуточный []byte; json.NewDecoder(r.Body).Decode(&v) не требует читать всё тело в память.
  3. Аллокации. Переиспользовать буферы через sync.Pool, задавать capacity, избегать map[string]interface{} (это боксинг и рефлексия на каждое поле) в пользу типизированных структур, избегать конвертаций []bytestring в горячем пути.
  4. Убрать рефлексию. 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; в проде на неё стоит смотреть после стабилизации.
  5. Сменить формат. Если обе стороны ваши — protobuf, msgpack, CBOR, FlatBuffers/Cap’n Proto (последние дают доступ без десериализации вообще). Для колоночных данных — Arrow/Parquet.
  6. Сжатие и сеть. Иногда «медленная сериализация» на самом деле медленная передача: gzip на каждый ответ может стоить дороже, чем экономит; проверять профилем.
  7. Измерить. Каждый шаг подтверждать бенчмарком с -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_space vs alloc_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-системы.