В Go очень легко быстро получить цифры и очень легко сделать из них неправильные выводы. Один удачный benchmark не означает, что код стал быстрее в реальной системе; один flame graph не означает, что найден главный bottleneck. Тема производительности полезна ровно тогда, когда измерения воспроизводимы и отвечают на конкретный инженерный вопрос.
Заключительная статья серии про Go в backend. Пройдём один сквозной пример от бенчмарка до профиля и трассы: когда писать benchmark, как читать pprof, чем полезны benchstat и go tool trace, что такое PGO — и какие ошибки чаще всего делают, когда «оптимизируют по ощущениям».
В статье
- benchmark или profiler: разные вопросы
- Сквозной пример
- Базовый benchmarking
- Конкурентные бенчмарки
- Как сравнивать честно: benchstat
- Профилирование: pprof
- Чтение профиля: flat/cum, list, escape
- Execution tracer
- PGO: profile-guided optimization
- Профилирование в проде
- Типичные ловушки
- Выводы
benchmark или profiler: разные вопросы
Это инструменты для разных вопросов:
- benchmark отвечает «насколько быстра вот эта функция/реализация» и «стало ли быстрее после изменения». Локально, на одном куске кода.
- profiler отвечает «куда уходит время/память во всём работающем сервисе» — то есть где вообще оптимизировать.
Порядок почти всегда такой: сначала profiler показывает горячую точку, потом benchmark меряет конкретную её замену. «Медленно» без сценария и цифры бесполезно — первый вопрос всегда «медленно где и насколько».
Сквозной пример
Возьмём одну функцию и доведём её от «медленно» до «быстро», меряя каждый шаг. Render собирает строку из событий — с классической ловушкой: конкатенация в цикле, где каждая итерация создаёт новую строку (O(n²) по памяти).
type Event struct{ User, Kind string }
// Медленно: конкатенация в цикле — каждая итерация создаёт новую строку (O(n^2) по памяти).
func Render(events []Event) string {
out := ""
for _, e := range events {
out += e.User + ":" + e.Kind + ";"
}
return out
}
// Быстро: один буфер через strings.Builder + оценка итогового размера.
func RenderFast(events []Event) string {
var b strings.Builder
b.Grow(len(events) * 16)
for _, e := range events {
b.WriteString(e.User)
b.WriteByte(':')
b.WriteString(e.Kind)
b.WriteByte(';')
}
return b.String()
}Дальше — доказываем цифрами, что «быстро» действительно быстрее, а не «на глаз». (И осторожно с «оптимизациями по ощущениям»: скажем, make(map, len(events)) для счётчика с немногими уникальными ключами не ускоряет, а раздувает память — такое ловит именно замер, а не интуиция.)
Базовый benchmarking
Бенчмарк — функция BenchmarkXxx(*testing.B). С Go 1.24+ цикл пишется через b.Loop(): он не учитывает setup и не даёт компилятору выкинуть измеряемый вызов.
func BenchmarkRender(b *testing.B) {
events := makeEvents(1000) // подготовка — вне измерения
b.ReportAllocs() // показать B/op и allocs/op
for b.Loop() { // Go 1.24+: корректный цикл
_ = Render(events)
}
}
// До Go 1.24 — классический цикл; результат кладут в пакетную
// переменную (sink), иначе компилятор удалит «бесполезный» вызов:
// for i := 0; i < b.N; i++ { sink = Render(events) }go test -bench=Render -benchmem -run=^$ ./...-benchmem (или b.ReportAllocs()) добавляет B/op и allocs/op — часто именно аллокации, а не «алгоритм», оказываются причиной. Тяжёлую общую подготовку выносите до цикла; setup внутри итерации оборачивайте в b.StopTimer()/b.StartTimer().
Конкурентные бенчмарки
Если код исполняется параллельно (хендлеры, пулы), меряйте его так же — через b.RunParallel: каждая горутина крутит pb.Next(), а флаг -cpu задаёт число процессоров.
func BenchmarkRenderParallel(b *testing.B) {
events := makeEvents(1000)
b.ReportAllocs()
b.RunParallel(func(pb *testing.PB) {
for pb.Next() {
_ = RenderFast(events)
}
})
}
// go test -bench=Parallel -cpu=1,2,4 -run=^$ ./...Полезные флаги: -benchtime=5s (или -benchtime=100000x — фиксированное число итераций для воспроизводимости), -count=N (несколько прогонов для статистики, см. ниже).
Как сравнивать честно: benchstat
Один прогон «до» и один «после» врут: соседний процесс, троттлинг CPU, GC дают разброс. Правильно — много прогонов и статистика через benchstat:
go install golang.org/x/perf/cmd/benchstat@latest # не входит в стандартный toolchain
go test -bench=Render -benchmem -run=^$ -count=10 > old.txt
# ... переключаемся на RenderFast (или вносим изменение) ...
go test -bench=Render -benchmem -run=^$ -count=10 > new.txt
benchstat old.txt new.txtbenchstat показывает не только дельту, но и разброс со значимостью:
│ old.txt │ new.txt │
│ sec/op │ sec/op vs base │
Render-8 1171.6µ ± 10% 12.0µ ± 11% -98.98% (p=0.000 n=8)
│ old.txt │ new.txt │
│ B/op │ B/op vs base │
Render-8 6115Ki ± 0% 16.0Ki ± 0% -99.74% (p=0.000 n=8)
│ old.txt │ new.txt │
│ allocs/op │ allocs/op vs base │
Render-8 1000.0 ± 0% 1.000 ± 0% -99.90% (p=0.000 n=8)
Читаем: столбец ± — разброс (здесь ± 10% по времени, ± 0% по allocs), p=0.000 — отличие значимо. Если стоит ~ или p большой — разницы, по сути, нет, как бы ни хотелось. Сравнивайте на одинаковых входных данных и без внешних зависимостей в цикле.
Профилирование: pprof
Benchmark говорит «стало быстрее», profiler — «куда уходит время/память». Профиль снимают из бенчмарка или с живого сервиса.
go test -bench=Render -run=^$ -cpuprofile=cpu.out -memprofile=mem.out . # один пакет: не ./...
go tool pprof -http=:8080 mem.out # веб-UI: flame graph, top, sourceВиды профилей и на что отвечают:
- CPU — где тратится процессорное время (hot path);
- heap — что занимает память сейчас (
inuse_space) и сколько аллоцировано всего (alloc_space); - allocs — источники аллокаций (частый корень нагрузки на GC);
- goroutine — сколько и где «висят» горутины (утечки);
- mutex / block — конкуренция за блокировки и ожидание (включить:
runtime.SetMutexProfileFraction,runtime.SetBlockProfileRate).
Чтение профиля: flat/cum, list, escape
В pprof начинают с top, но ключ — понимать две колонки:
- flat — время/память в самой функции;
- cum — вместе со всем, что она вызвала.
Высокий cum при низком flat — функция сама дешёвая, но тянет дорогих потомков; высокий flat — тратит она сама.
go tool pprof mem.out
(pprof) top # главные потребители
(pprof) top -cum # по кумулятивному
(pprof) list Render # построчно: какая строка аллоцируетВ нашем примере list Render укажет прямо на строку out += .... Почему именно там аллокации — покажет escape analysis: компилятор объясняет, что уехало в heap (для нашей строки — ... escapes to heap).
go build -gcflags="-m" ./... # "escapes to heap", "moved to heap", решения по inliningДальше — приёмы: преаллокация (make(map, n), slice = make([]T, 0, n)), strings.Builder вместо +=, переиспользование буферов через sync.Pool, отказ от лишнего боксинга в interface{}. Но применять их стоит только по профилю, а не «везде на всякий случай».
Execution tracer
pprof отвечает «где горит CPU/память», но не «почему запрос завис на 200 мс, пока CPU простаивал». Это уже про планирование горутин, блокировки, GC-паузы, сетевые ожидания — их видит execution tracer.
go test -bench=Render -run=^$ -trace=trace.out . # профильные флаги — по одному пакету
go tool trace trace.out # таймлайн горутин, GC, syscalls, scheduler latency
# с живого сервиса: go tool trace http://localhost:6060/debug/pprof/trace?seconds=5Правило выбора: CPU-bound и «жрёт процессор» → pprof; latency, «висит», неравномерность, GC-паузы → go tool trace.
PGO: profile-guided optimization
С Go 1.21 компилятор умеет использовать реальный CPU-профиль для оптимизации сборки (агрессивнее инлайнит горячие пути). Механизм простой: положить CPU-профиль как default.pgo рядом с main-пакетом — go build подхватит его автоматически.
# 1. снять репрезентативный CPU-профиль с прода
curl -o default.pgo 'http://prod:6060/debug/pprof/profile?seconds=30'
# 2. положить рядом с main и собрать — сборка сама применит PGO
go build ./...Выигрыш обычно единицы процентов и зависит от того, насколько профиль репрезентативен. Это не замена ручной оптимизации горячих мест, а бесплатная добавка сверху — при условии, что профиль снят с реальной нагрузки, а не с синтетики.
Профилирование в проде
Локальный бенчмарк не покажет, что медленно на реальном трафике. Для живого сервиса — net/http/pprof:
import _ "net/http/pprof" // регистрирует /debug/pprof на DefaultServeMux
// go func() { log.Println(http.ListenAndServe("localhost:6060", nil)) }()
// затем: go tool pprof http://localhost:6060/debug/pprof/profile?seconds=30Что важно в проде:
- Не вешайте pprof на публичный порт. Отдельный внутренний listener (
localhost:6060) или защита — профили выдают внутренности. - Метки профиля (labels) — привязывают участки CPU-профиля к бизнес-контексту (endpoint, tenant), чтобы видеть, чей трафик горячий:
pprof.Do(ctx, pprof.Labels("endpoint", "/render"), func(ctx context.Context) {
handle(ctx) // работа под этой меткой видна в профиле отдельно
})- Оверхед. CPU-профиль сэмплирует ~100 Гц — стоит дёшево, можно снимать в проде короткими окнами. Heap-профиль управляется
runtime.MemProfileRate. Continuous profiling (Pyroscope, Grafana, Datadog) — тот жеpprofна постоянной основе, чтобы ловить регрессии, а не бегать с профайлером после инцидента.
Типичные ловушки
- Бенчмарк меряет не то. Компилятор выкинул вызов (нет
b.Loop()/sink) или в измерение попал setup. Результат «0.3 ns/op» — почти всегда ошибка измерения. - Один прогон вместо статистики. Кэш, GC, прогрев искажают; всегда
-count=N+benchstat, а не «один раз до/после». - I/O, сеть, БД нельзя честно оценить локальным микробенчмарком — там доминируют латентность и контеншн, а не CPU функции; это к
go tool traceи профилю с прода. - Microbenchmark ≠ производительность системы. Ускорили функцию на 40%, а она и так была 1% профиля — выигрыша нет. Сначала profiler (где), потом benchmark (насколько).
- Оптимизация без профиля. «Тут наверняка медленно» — самая дорогая догадка. Профиль почти всегда указывает не туда, куда показывала интуиция.
Выводы
- Сначала измерять, потом оптимизировать. Интуиция о «горячих местах» ошибается чаще, чем кажется.
- Инструменты дополняют друг друга: profiler находит где, benchmark подтверждает насколько,
benchstat— значимо ли,go tool trace— почему latency, PGO — бесплатная добавка от реального профиля. - Цифра без сценария ничего не значит — фиксируйте входные данные, окружение и вопрос, на который отвечаете.
- Это перекликается с принципом из статьи про async: не угадывать узкое место, а измерять.
На этом серия про Go в backend завершена: от конфигурации и graceful shutdown через работу с БД до измеримой производительности.
Комментарии