---
title: "Бенчмарки и профилирование"
description: "Бенчмарки в Go: func BenchmarkXxx и подбор b.N, go test -bench -benchmem (ns/op, B/op, allocs/op), ResetTimer, pprof профилирование и flame graph."
course: "Продвинутые возможности Go: от замыканий до рефлексии"
url: "https://golangmaster.ru/courses/go-advanced/lessons/17-benchmarki/"
published: "2026-09-19"
updated: "2026-09-21"
minutes: 22
---

# Бенчмарки и профилирование

*Курс «Продвинутые возможности Go: от замыканий до рефлексии», урок 17 из 25. ~22 минут. Обновлено 21.09.2026. Источник: https://golangmaster.ru/courses/go-advanced/lessons/17-benchmarki/*

«Бенчмарки go» — запрос, который приходит сразу после первого замедления: программа работает, тесты зелёные, но где-то внутри прячется секунда, которую никто не заказывал. В шестнадцатом уроке мы научились проверять, что код делает правильные вещи; теперь научимся измерять, как быстро он это делает. Пакет testing хранит второй инструментарий рядом с первым: бенчмарка — та же функция в том же файле *_test.go, только сигнатура func BenchmarkXxx(b *testing.B) и внутри цикл по b.N, число итераций которого подбирает сам фреймворк. Команда go test -bench напечатает наносекунды на операцию, флаг -benchmem добавит байты и аллокации, а pprof покажет flame graph, внутри которого подозрения превращаются в факты. Разберём всё по столбцам: как testing нащупывает b.N, зачем нужен ResetTimer, как читать ns/op, B/op и allocs/op, и как один кейс со строковой конкатенацией ускоряется в десять раз на глазах. Бенчмарки здесь — честные листинги для настоящего go test: движки песочницы тестовый прогон не заменяют, зато ручные симуляции замеров гоняются прямо на странице.

### Бенчмарки в Go: перестаньте угадывать — начните измерять

Шестнадцатый урок закрыл вопрос «правильно ли работает код»; этот урок закрывает вопрос «насколько быстро». Разница между ними принципиальная, потому что у скорости есть коварное свойство: она почти всегда не там, где вы думаете. Программист трижды переписывает парсер — а тормозит печать в лог; команда переезжает на новый фреймворк — а девяносто процентов времени съедает сериализация, которая была и в старом. По запросу «бенчмарки go» обычно ищут инструмент, который поставит точку в спорах, и он действительно есть, причём в той же стандартной библиотеке: пакет testing умеет не только проверять, но и измерять.

Бенчмарка — это функция в файле с суффиксом _test.go (да, рядом с тестами, в том же каталоге), имя которой начинается с префикса Benchmark и продолжается с заглавной буквы: BenchmarkConcat, BenchmarkNormalizePhone. Единственный параметр — указатель на testing.B, «руль» измерения: через него фреймворк сообщает число итераций (b.N), управляет таймером (ResetTimer, StopTimer, StartTimer) и просит печатать аллокации (ReportAllocs). Внутри — цикл по b.N, тело которого и есть измеряемая операция. Запускается всё той же командой go test, только с флагом -bench: никакой отдельной инфраструктуры, второй мир живёт в первом.

Что будет в уроке. Сначала контракт: сигнатура, файл, и главное — кто и зачем подбирает b.N, потому что именно здесь новички теряются (число в отчёте меняется от запуска к запуску, и это нормально). Затем чтение отчёта go test -bench по столбцам: ns/op, флаг -benchmem и его подарки B/op и allocs/op. Потом гигиена замера: ResetTimer, StopTimer и StartTimer для честного окна измерения. Затем pprof профилирование — снятие CPU-профиля и профиля памяти, чтение top и list, базовый flame graph. Финал — живой кейс: строковая конкатенация против strings.Builder, где квадратичный алгоритм на глазах становится линейным, а отчёт -benchmem объясняет почему.

Оговорка о песочнице, как в прошлом уроке. Настоящий бенчмарк — это работа команды go test: она собирает пакет, находит Benchmark-функции и прогоняет их с подобранным b.N. Ни один движок песочницы этого не делает (у резервного пакета testing нет вовсе, у основного нет команды test), поэтому Benchmark-функции урока — листинги для локального запуска, а интерактивы честно гоняют то, что движкам по силам: ручные счётчики операций, симуляцию подгонки b.N, фальшивые часы для ResetTimer и битву конкатенаций. Принцип тот же, что в шестнадцатом уроке: смотрите на настоящие листинги — тут же трогайте их логику руками.

### func BenchmarkXxx: контракт, который вы уже знаете

Хорошая новость: половину контракта вы уже сдали. Файл с суффиксом _test.go — тот же, go build его молча пропускает, go test — компилирует. Префикс имени Benchmark с заглавным продолжением — по образцу префикса Test; флаг -bench фильтрует бенчмарки регуляркой так же, как -run фильтрует тесты. Возвращать функция ничего не должна — измерение выражается не return, а самим фактом прогонки цикла. Меняется только руль: вместо *testing.T приходит *testing.B, и у него своя задача — не фиксировать провалы, а крутить счётчик итераций.

Центральное действующее лицо — **b.N**. Это число повторов измеряемой операции, и его задаёт НЕ программист: его подбирает фреймворк по ходу запуска. Механика честная и простая: testing запускает бенчмарку с малым N, смотрит на затраченное время, затем удваивает (и повторяет этот подгон, отрезая неудачные прогоны), пока суммарное время не станет достаточно долгим для надёжного измерения — по умолчанию около секунды на бенчмарку (управляется флагом -benchtime). Финальный прогон с последним N и попадает в отчёт: поэтому число итераций в выводе каждый раз разное — 4908816, 5102311, 4782195 — и это не шум, а следствие подгонки. Писать b.N = 1000 руками бессмысленно и не нужно: фреймворк сам обеспечит и достаточную длительность, и деление на N при расчёте наносекунд на операцию.

Отсюда и классическая форма, которую вы увидите в миллионах строк по всему миру:

for i := 0; i < b.N; i++ {
        // измеряемая операция
}

Честности ради: начиная с Go 1.24 у testing.B появился метод b.Loop() — новый стиль, где ручной счётчик не нужен (for b.Loop() { ... }), а компилятору проще не выбрасывать неиспользуемый результат. Но классическая форма с b.N остаётся универсальной: она работает во всех версиях, встречается в любом старом коде и документации, и именно её мы разбираем по буквам. Выучив её, вы прочитаете b.Loop за минуту — это просто оболочка над тем же контрактом.

Единственная ловушка внутри цикла — компилятор и мёртвый код. Если результат операции никак не используется, компилятор теоретически вправе выкинуть работу (в старых версиях так и было), и бенчмарк начнёт измерять пустоту. Лечится чтением результата: присвойте его внешней переменной и добавьте _ = result после цикла — как в листинге ниже. Новый b.Loop решает это автоматически, но в классической форме дисциплина на вас.

### Листинг: первая бенчмарка — классическая форма с b.N

```go
// concat_test.go — бенчмарки живут рядом с тестами, в файле *_test.go.
package concat

import "testing"

// Concat склеивает кусочки через += — так НЕ надо, скоро увидим почему.
func Concat(parts []string) string {
        s := ""
        for _, p := range parts {
                s += p // каждая итерация копирует всю накопленную строку заново
        }
        return s
}

// BenchmarkConcat — префикс Benchmark + имя с заглавной буквы; руль *testing.B.
func BenchmarkConcat(b *testing.B) {
        parts := []string{"a", "b", "c", "d", "e"}
        // Классическая форма: testing сам подберёт b.N — руками не задаём.
        for i := 0; i < b.N; i++ {
                Concat(parts)
        }
        // Читаем результат, чтобы компилятор не выбросил работу как мёртвый код.
        _ = Concat(parts)
}
```

Листинг — для настоящего go test: в песочнице пакет testing движкам недоступен. Обратите внимание на три вещи: prepare-данные (parts) создаются ОДИН раз до цикла — иначе вы измерите и их создание; b.N нигде не присваивается — это только входное число итераций; и хвост _ = Concat(parts) — страховка от мёртвого кода. Начиная с Go 1.24 тот же код можно записать как for b.Loop() { Concat(parts) } — метод сам сбрасывает таймер перед первым вызовом и защищает от выкидывания результата, но b.N-форма остаётся классикой, читаемой в любом репозитории.

### Проверьте себя

**Вопрос:** Откуда берётся число итераций b.N в бенчмарке func BenchmarkXxx(b *testing.B)?

- Программист задаёт его вручную перед циклом: b.N = 1000
- ✅ Фреймворк подбирает сам: запускает с малым N и удваивает, пока прогон не станет достаточно долгим (по умолчанию около секунды)
- Оно всегда равно 100 — константа пакета testing
- Число совпадает с количеством тестов в пакете: по одному повтору на тест

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

### go test -bench: читаем отчёт по столбцам

Запуск — та же команда go test с флагом -bench и регуляркой: -bench=. запускает все бенчмарки пакета, -bench=Concat — только совпавшие с именем. Первая тонкость: обычные тесты при этом всё равно выполнятся, и их время разбавит прогон; если они вам сейчас не нужны, отключите их фильтром, которому не совпадает ни один тест: go test -bench=. -run='^$'. Это каноническая команда бенчмаркинга — запомните её парой с флагом -benchmem, о котором ниже.

Отчёт печатается столбцами, и каждый столбец имеет точный смысл. Строка `BenchmarkConcat-8    4908816    245.3 ns/op` читается так: имя бенчмарки и суффикс -8 — это GOMAXPROCS, число логических процессоров, при котором шёл прогон (полезно, когда сравниваете машины); 4908816 — подобранное b.N, число итераций финального прогона; 245.3 ns/op — средняя стоимость ОДНОЙ итерации в наносекундах, главный столбец урока. Перед данными go test печатает паспорт окружения — goos, goarch, pkg, cpu — и это не украшение: абсолютные наносекунды сравнивают только на одном железе, поэтому паспорт нужен в каждом отчёте.

Флаги управления прогоном продолжают знакомую логику. -benchtime=2s заставит фреймворк крутить подгон дольше — полезно для шумных сред, где секунды не хватает; -benchtime=100x задаёт фиксированное число итераций — быстро проверить, что бенчмарка вообще живая. -count=5 повторяет весь прогон пять раз: ОДИН прогон — это анекдот, пять — статистика. Между повторами разброс неизбежен (планировщик, кэш, соседние процессы), поэтому всерьёз выводы делают не по одному числу, а по медиане нескольких прогонов; у benchstat — маленькой утилиты из golang.org/x/perf — для этого есть готовый отчёт с процентами и вердиктом «отличие статистически значимо или нет».

И последнее: бенчмарка, которая печатает в stdout, портит и прогон, и отчёт. Печать — самая дорогая операция на свете относительно арифметики, и ваши 245.3 ns/op мгновенно станут 4200 ns/op от одного fmt.Println внутри цикла. Правило то же, что у тестов: молчи, пока здоров — печать только в prepare-фазе или за пределами измеряемого окна.

### Подгон b.N: как testing нащупывает число итераций

Это устройство подгонки b.N без времени: вместо секунд — условные такты, вместо наращивания по факту замера — чистая функция n*n. Запустите и посмотрите на траекторию: 1, 2, 4, 8, 16, 32 — удвоение нащупывает цель за log2 шагов, поэтому даже миллиарды итераций подбираются за пару десятков прогонов. Замените цель 1000 на 100 — цепочка укоротится: тот же алгоритм, что у настоящей подгонки, только цель в тактах.

```go
package main

import "fmt"

func main() {
        // Симуляция подгонки: фреймворк начинает с малого N и удваивает,
        // пока «стоимость» прогона не достигнет цели (в реале — ~1 секунды).
        n := 1
        for {
                // «Стоимость» прогона n итераций: каждая операция стоит такт.
                cost := n * n
                fmt.Printf("n=%d cost=%d\n", n, cost)
                if cost >= 1000 {
                        break // цель достигнута: измеряем на этом n
                }
                n *= 2 // экспоненциальный рост: 1, 2, 4, 8, 16...
        }
}
```

### -benchmem: B/op и allocs/op — аллокации под подозрением

Наносекунды говорят «сколько», но не говорят «почему». Для «почему» в go test есть флаг -benchmem, и это самый информативный символ на весь бенчмаркинг: go test -bench=. -benchmem=-run='^$' дописывает к отчёту два столбца. `10 B/op` — сколько БАЙТ аллокировал один проход измеряемой операции; `4 allocs/op` — сколько РАЗ за один проход вызвался аллокатор. Это средние значения по всем итерациям, и читать их нужно в связке: allocs/op показывает количество походов к аллокатору (каждый — фиксированная цена и потенциальный pressure на GC), B/op — объём трафика.

Почему аллокации — главный подозреваемый любого торможения в Go? Потому что память в куче дорогая не сама по себе, а последствиями: аллокатор должен найти свободный блок (а при фрагментации — и поискать), GC со временем обязан эти объекты собрать, а кэш процессора — промахнуться по разбросанным данным. Две функции с одинаковым ns/op «здесь и сейчас» могут вести себя совершенно по-разному под нагрузкой: у первой аллокаций ноль, вторая каждую итерацию мусорит — и именно вторая утопит сервис под процентами GC. Отсюда правило бенчмаркинга: сравнивая варианты, смотрите на allocs/op РАНЬШЕ, чем на ns/op — столбец аллокаций почти всегда называет виновника быстрее, чем наносекунды.

Аллокации считают и без флага: метод b.ReportAllocs() внутри бенчмарки включает столбцы только для неё — удобно, когда флаг для всего пакета не нужен. А чтобы увидеть не только количество, но и ЛИЦА виновников, аллокации подключаются к профилированию памяти: флаг -memprofile, о котором через два раздела, сохранит по стекам, КТО именно аллокирует эти байты. ns/op отвечает «медленно ли», allocs/op — «кто виноват», flame graph — «где именно»: три вопроса, три инструмента, один go test.

И честная оговорка про кэш и warming-up: первый прогон бенчмарки греет кэши, подгружает страницы кода и словари, и потому всегда медленнее стабильных. Фреймворк знает это и неудачные короткие прогоны при подгоне отбрасывает, но при -count=5 первый повтор может выбиваться — не паникуйте и не выбрасывайте его руками без причины: сначала посмотрите на медиану.

### ResetTimer, StopTimer, StartTimer: чистое окно замера

Бенчмарка измеряет ВСЁ, что происходит между началом и концом цикла — и туда легко попадает мусор, который к операции отношения не имеет. Классическая ловушка: подготовка данных. parts := prepareParts(1000000) внутри бенчмарки до цикла кажется безобидным — но если prepareParts аллокирует и считает десятки миллисекунд, а сама операция стоит микросекунды, то ns/op будет измерять в основном подготовку... нет, стоп: подготовка ДО цикла в суммарное время не попадает — таймер стартует с началом замера. Попадает в замер она тогда, когда вы делаете её ВНУТРИ цикла, когда каждый кейс требует свежих данных, или когда таймер уже тикает из-за подгоночных прогонов. Точнее и опаснее всего второй случай: любое одноразовое дорогое действие после старта замера — подключение к базе, чтение файла, генерация входа — размазывается по всем итерациям и врёт вам в каждом ns/op.

**b.ResetTimer()** — лечение по умолчанию: обнуляет таймер (и счётчики аллокаций) в точке вызова. Подготовили данные — вызовите ResetTimer — крутите цикл: замер начнётся с чистого листа, подгоночные прогоны и подготовка не попадут в отчёт. Это стандартный хвост любой бенчмарки с setup-фазой, и его отсутствие — самая частая причина «почему у меня в отчёте 8000 ns/op, а операция-то копеечная».

Когда подготовка нужна КАЖДОЙ итерации (свежий буфер, чистое состояние), флагов «до цикла» недостаточно — и на сцену выходит пара **b.StopTimer() / b.StartTimer()**: первый замирает счёт времени, второй запускает снова. Подготовка внутри итерации оборачивается в StopTimer-подготовку-StartTimer, и в ns/op попадает только операция. Цена честности — сама пара вызовов: они не бесплатные, и при копеечных операциях их накладные расходы могут быть заметны. Поэтому иерархия такая: подготовка вне цикла и без таймеров — идеал; ResetTimer после одноразового setup — норма; StopTimer/StartTimer на каждую итерацию — приём с оговоркой, для дорогих операций. Отдельный бонус ResetTimer: он сбрасывает и счётчики аллокаций, так что B/op и allocs/op после него тоже чистые.

### Листинг: ResetTimer в бою — подготовка вне измерения

```go
// benchmark_setup_test.go — замер с подготовкой и без неё.
package concat

import "testing"

// prepareParts строит входные данные — ДОРОГО, но один раз на прогон.
func prepareParts(n int) []string {
        parts := make([]string, 0, n)
        for i := 0; i < n; i++ {
                parts = append(parts, "кусочек")
        }
        return parts
}

// Вариант 1: подготовка до цикла — таймер ещё не тикает, всё честно само.
func BenchmarkConcatPrepared(b *testing.B) {
        parts := prepareParts(1000) // setup вне измеряемого окна
        b.ResetTimer()              // страховка: часы и аллокации на нуль
        for i := 0; i < b.N; i++ {
                Concat(parts)
        }
        _ = Concat(parts)
}

// Вариант 2: подготовка НУЖНА каждой итерации — StopTimer/StartTimer.
func BenchmarkConcatFresh(b *testing.B) {
        for i := 0; i < b.N; i++ {
                b.StopTimer()               // часы замерли
                parts := prepareParts(10)   // подготовка НЕ попадёт в замер
                b.StartTimer()              // часы пошли: измеряется только это
                Concat(parts)
        }
}
```

Два паттерна на одном листинге: одноразовый setup закрывает ResetTimer (в вариант 1 он формально избыточен — подготовка до старта замера — но дисциплина «setup → ResetTimer → цикл» спасает, когда setup переедет или появится второй), а per-итерационный — парой StopTimer/StartTimer. Настоящий go test -bench по этому файлу покажет у BenchmarkConcatFresh ns/op заметно выше, чем у Prepared: готовить вход каждой итерации дорого даже с замороженными часами — сам вызов prepareParts не исчез, он просто не считается в замере. В песочнице листинг не исполняется: пакет testing движкам недоступен, запуск — локальным go test -bench=ConcatPrepared -benchmem.

### Фальшивые часы: грязный замер и ResetTimer

Операция стоит один такт, подготовка — девятьсот; в замере десять итераций. Запустите: без сброса часы показывают 910 тактов — девяносто один такт на операцию, в девяносто один раз вранья. Сброс часов перед циклом — и отчёт честен: ровно 10 тактов. Поменяйте местами строки сброса и подготовки — вранье вернётся: ResetTimer обязан стоять ПОСЛЕ setup-работы и ДО измеряемого цикла.

```go
package main

import "fmt"

// clock — фальшивые часы: вместо наносекунд считаем условные такты.
var clock int

func main() {
        // Замер 1: подготовка попала в окно замера (забыли ResetTimer).
        clock = 0
        clock += 900 // prepareExpensive(): дорого, но к операции отношения не имеет
        for i := 0; i < 10; i++ {
                clock += 1 // сама операция: ровно такт на итерацию
        }
        fmt.Println("без ResetTimer:", clock, "тактов на 10 операций")

        // Замер 2: то же самое, но часы сброшены после подготовки.
        clock = 0
        clock += 900 // prepareExpensive(): та же цена
        clock = 0    // b.ResetTimer(): окно замера открывается с нуля
        for i := 0; i < 10; i++ {
                clock += 1
        }
        fmt.Println("с ResetTimer:", clock, "тактов на 10 операций")
}
```

### pprof профилирование: куда ушла секунда

Бенчмарка отвечает «сколько стоит операция», но когда операция дороже ожиданий, начинается поиск виновника — и вот здесь начинается pprof профилирование. Профайлер — это инструмент, который в ходе работы программы периодически записывает, в какой функции она находится (CPU-профиль), и кто сколько памяти аллокирует (профиль памяти). Результат — не средние наносекунды, а РАЗЛОЖЕНИЕ времени и памяти по стекам вызовов: конкретные функции с конкретными долями. Философия простая: бенчмарк нашёл боль, профиль назвал её адрес.

Снять профиль с бенчмарок проще всего: go test -bench=Concat -cpuprofile=cpu.out соберёт бенчмарки как обычно и попутно запишет CPU-профиль в файл cpu.out; флаг -memprofile=mem.out тем же манером сохранит профиль памяти (аллокации по стекам). Файл называется профилем и хранит семплы; читать его будет утилита go tool pprof, о ней ниже. Тонкость, которую стоит знать: CPU-профиль собирается семплированием — примерно сто раз в секунду рантайм прерывает программу и записывает текущий стек; поэтому в профиле попадают только функции, которые реально выполнялись достаточно долго, а микроскопические вызовы тонут в шуме. Это не баг, а дизайн: профайлер показывает, где программа ЖИВЁТ, а не где она побывала мимоходом.

Если профилировать нужно не бенчмарку, а обычную программу или сервер, на сцену выходит пакет runtime/pprof из стандартной библиотеки: pprof.StartCPUProfile(f) включает запись семплов в файл f, defer pprof.StopCPUProfile() выключает по выходу; pprof.WriteHeapProfile(mf) снимает снимок кучи. HTTP-серверы получают то же самое в подарок: import _ "net/http/pprof" добавляет к default serve mux эндпоинт /debug/pprof/, с которого профиль живого сервера снимается по требованию — но это тема про HTTP, а здесь важно другое: источник профиля может быть любым, формат один, и go tool pprof читает все.

Осторожность одна: профиль без нагрузки — пустой профиль. CPU-профиль снимается только с того кода, который РЕАЛЬНО крутится во время записи: запустите бенчмарку под -cpuprofile — и семплы соберутся; снимите профиль с программы, которая спит в ожидании сети, — и top покажет рантайм-ожидания, а не вашу логику. Правило: сначала нагрузка (бенчмарка или реальный трафик), потом запись, потом чтение.

### Листинг: runtime/pprof в обычной программе

```go
// main.go — снимаем профили вручную через runtime/pprof.
package main

import (
        "os"
        "runtime/pprof"
)

// hot — «горячая» функция: её и увидим в top профиля.
func hot() {
        total := 0
        for i := 0; i < 100000000; i++ {
                total += i % 7
        }
        _ = total
}

func main() {
        // CPU-профиль: семплы стека пишутся в файл, пока профиль активен.
        f, _ := os.Create("cpu.out")
        pprof.StartCPUProfile(f)
        defer pprof.StopCPUProfile() // выключение при любом исходе — как defer

        hot() // под профилем крутится только это

        // Профиль памяти: снимок кучи — кто и сколько аллокировал по стекам.
        mf, _ := os.Create("heap.out")
        pprof.WriteHeapProfile(mf)
        mf.Close()
}
```

Для бенчмарок этот танец не нужен — go test -cpuprofile=cpu.out -bench=Concat делает то же самое автоматически. Листинг — для программ и утилит: запустили с профилем, отработали, прочитали cpu.out утилитой go tool pprof. Ошибки os.Create в листинге проглочены для краткости — в боевом коде их обязаны проверять (как в пятнадцатом уроке: ошибка — значение, а не заглушка). В песочнице не исполняется: пакеты os и runtime/pprof движкам недоступны, это листинг для локального запуска.

### Чтение профилей: top, list и flame graph

Файл профиля читает go tool pprof: go tool pprof cpu.out открывает интерактивную сессию, где три команды закрывают девяносто процентов потребностей. **top** — таблица функций-рекордсменов: `top5` покажет пять функций, на которые ушло больше всего времени, столбцом flat — время непосредственно в теле функции, столбцом cum (cumulative) — время в ней и во всём, что она вызывала. Первая строка top с большим flat — это и есть виновник: в нашем кейсе строка вида `1.31s 72.78% 72.78% example.com/concat.Concat` скажет всё — три четверти времени прогона сидит в Concat. Если flat маленький, а cum большой — время живёт в callee, спускайтесь по стеку.

**list** — второй уровень точности: list Concat печатает исходник функции построчно с процентом времени на каждую строку. Это уже не «функция дорогая», а «вот эта строка с конкатенацией — 70%, а вот эта проверка — 2%»: уровень детализации, после которого оптимизация перестаёт быть гаданием. Третья команда — **flame graph**: go tool pprof -http=:8080 cpu.out откроет браузерный интерфейс, где вкладка VIEW → Flame Graph нарисует стеки столбиками: ширина столбика пропорциональна доле времени, глубина — стеку вызовов. Читается базово за минуту: широкий плоский столбик наверху — дорогая функция, широкая башня — дорогая ветка вызовов; ищите самый широкий столбик, который ВЫ контролируете (не рантайм и не syscall), — это и есть точка приложения усилий.

flame graph любит два вопроса: «широкий столбик наверху» и «странный хребет посередине». Первый — прямая дорога к оптимизации; второй (узкая, но глубокая башня одинаковых вызовов) — часто признак рекурсии или бесконечных обёрток, и он тоже виден мгновенно. Не углубляйтесь дальше первого широкого столбика: профилирование — не коллекционирование, а поиск ДВЕРИ, за которой сидит половина времени; найдя её, закрывайте сессию и идите писать бенчмарк для проверки правки.

И держите в голове честную иерархию инструментов, чтобы не путаться: ns/op из go test -bench отвечает «дорого ли»; allocs/op из -benchmem отвечает «сколько мусора»; top и list из pprof отвечают «кто и в какой строке»; flame graph — «как через стек это пришло». Четыре ответа, четыре команды — весь бенчмаркинг стандартной библиотеки.

> **Честно о бенчмарках в песочнице.** Ни один движок песочницы не запускает бенчмарки — это работа настоящего go test, собирающего пакет и подбирающего b.N. Резервный движок на import "testing" отвечает отказом: пакет "testing" недоступен в песочнице (доступны: fmt, strings, strconv, math, sort, errors, time); основной движок (yaegi) импорт testing допускает и даже компилирует Benchmark-функции с b.N, но тестовый прогон в браузере отсутствует в принципе. Пакеты runtime/pprof и os в фолбэке тоже не поддержаны — pprof-листинги только для локального запуска. Поэтому интерактивы урока честно гоняют симуляции: счётчик копирований вместо наносекунд, фальшивые часы вместо таймера testing, подгон n по удвоению вместо -benchtime. А настоящие листинги скопируйте в каталог с go.mod и запустите локально: go test -bench=. -benchmem -run='^$' — только так вы увидите живые ns/op на своём железе.

### Кейс: строковая конкатенация против strings.Builder

Всё из этого урока сходится в один классический кейс, который стоит пройти целиком: строковая конкатенация в цикле. Строки в Go неизменяемы — это фундаментальное свойство, а не реализация: строка — пара (указатель, длина) на непрозрачные байты, и «изменить» её нельзя, можно только построить новую. Поэтому каждый s += p делает честную работу: аллокирует НОВЫЙ байтовый блок нужной длины, копирует туда ВСЁ старое содержимое и добавляет кусочек. На пяти кусочках это незаметно; на тысячах — суммарно копируется квадратичное число байтов: 1 + 2 + 3 + ... + n, то есть n(n-1)/2.

Противопоставим ему **strings.Builder** — тип из стандартной библиотеки, созданный ровно для этой задачи (Go 1.10). Builder держит внутренний []byte, который растёт с запасом (удвоением ёмкости, как срезы из ранних уроков), метод WriteString дописывает в конец без копирования всего накопленного, а финальный String() отдаёт результат. Итог: одна цепочка растущего буфера вместо n новых строк — линейное число копируемых байтов вместо квадратичного, и в разы меньше походов к аллокатору.

Как это доказать, а не постулировать? Тремя шагами этого урока. Шаг первый: счётчик копирований — ручной раннер ниже посчитает суммарные байты, скопированные при +=, и вы увидите квадратичный рост своими глазами: удвоили вход — работа выросла вчетверо. Шаг второй: настоящий go test -bench=. -benchmem по листингам BenchmarkConcat и BenchmarkBuilder покажет столбцы вида `245.3 ns/op 10 B/op 4 allocs/op` против `20.6 ns/op 8 B/op 1 allocs/op` (числа с одного ноутбука; у вас абсолюты будут другими — разрыв сохранится): allocs/op упал вчетверо, ns/op — на порядок. Шаг третий: cpuprofile того же прогона — и top покажет, что в первой версии почти всё время сидело в runtime.memmove и mallocgc, то есть в копировании и аллокациях, а не в вашей «логике». Подозрение → измерение → адрес виновника → правка → повторный замер: полный цикл, который вы теперь умеете.

Мораль кейса шире строк. Когда бенчмарк называет виновником аллокации, лечение почти всегда одно из трёх: переиспользовать буфер (Builder, bytes.Buffer, срез с запасом через make с capacity), вытянуть работу за пределы горячего цикла, или сменить структуру данных. Пере-читайте этот абзац после каждого профиля, где top упрётся в mallocgc — это самый частый диагноз в реальных сервисах, и лечится он без экзотики.

### Бенчмаркаем без testing: счётчик копирований

Вот ручной эквивалент того, что -benchmem покажет столбцом B/op: сколько байтов скопировал += за весь цикл. Запустите: десять итераций, 45 скопированных байтов — сумма 0+1+2+...+9. Теперь замените 10 на 20: байты вырастут до 190, а на 40 — до 780: вход удвоился, работа выросла в четыре раза. Это и есть квадратичное поведение, которое в настоящем бенчмарке выглядит как взлетающий ns/op на больших входах.

```go
package main

import "fmt"

func main() {
        s := ""
        copies := 0
        for i := 0; i < 10; i++ {
                copies += len(s) // += копирует всю старую строку заново
                s += "x"
        }
        fmt.Println(s, copies)
}
```

### Что напечатает счётчик при удвоениях входа?

```go
package main

import "fmt"

// copied считает суммарные байты-копии при += на n итерациях.
func copied(n int) int {
        total := 0
        s := ""
        for i := 0; i < n; i++ {
                total += len(s) // += копирует всю строку заново
                s += "x"
        }
        return total
}

func main() {
        fmt.Println("n=10:", copied(10))
        fmt.Println("n=20:", copied(20))
        fmt.Println("n=40:", copied(40))
}
```

**Что выведет программа?**

- ✅ n=10: 45, n=20: 190, n=40: 780 — рост квадратичный: удвоение входа умножает работу примерно вчетверо
- n=10: 45, n=20: 90, n=40: 180 — рост линейный: во сколько раз больше итераций, во столько же больше копий
- n=10: 45, n=20: 45, n=40: 45 — счётчик не растёт: копии дешевле, чем кажется
- n=10: 10, n=20: 20, n=40: 40 — счётчик равен числу итераций: одна копия на шаг

На i-й итерации += копирует i байтов накопленной строки, итого сумма 0+1+...+(n-1) = n(n-1)/2: для 10 это 45, для 20 — 190, для 40 — 780. Отношение 190/45 ≈ 4.2 и 780/190 ≈ 4.1 — и есть квадратичность: удвоение входа умножает работу вчетверо. Именно поэтому рост ns/op у += бенчмарков нелинеен, а strings.Builder с растущим буфером укладывает ту же работу в линейные O(n) байтов копирования.

### strings.Builder против +=: решающий бой

Резервный движок не знает strings.Builder — при отказе «у пакета strings нет члена Builder» переключитесь на основной движок: раннер нативный. Сравните два способа на одинаковом результате: у += счётчик копирований 45, у Builder ноль промежуточных копий — буфер растёт удвоением ёмкости, и каждый WriteString дописывает только новый кусочек. В настоящем бенчмарке это же покажет -benchmem: 4 allocs/op у += против 1 allocs/op у Builder.

```go
package main

import (
        "fmt"
        "strings"
)

func main() {
        const n = 10
        // Способ 1: += — каждая итерация копирует всё накопленное заново.
        s := ""
        copies := 0
        for i := 0; i < n; i++ {
                copies += len(s)
                s += "x"
        }
        fmt.Println("+=:", s, "копирований:", copies)

        // Способ 2: strings.Builder — растёт в буфере, копий по дороге нет.
        var b strings.Builder
        for i := 0; i < n; i++ {
                b.WriteString("x")
        }
        fmt.Println("Builder:", b.String(), "— результат тот же")
}
```

### Столбцы -benchmem

**Вопрос:** Что означает столбец 4 allocs/op в отчёте go test -bench=. -benchmem?

- Число аллокаций за весь прогон бенчмарки: всего 4 на миллион итераций
- ✅ Среднее число вызовов аллокатора на ОДНУ итерацию измеряемой операции
- Размер в байтах, который аллокатор держит зарезервированным для горутины
- Число объектов, собранных сборщиком мусора за время прогона

allocs/op — среднее по итерациям число аллокаций на один проход измеряемой операции; соседний B/op — средний объём этих аллокаций в байтах. Оба столбца включает флаг -benchmem (или метод b.ReportAllocs()), и оба сбрасываются вместе с таймером по b.ResetTimer(). Столбец аллокаций — самый быстрый способ назвать виновника: большой allocs/op почти всегда означает «иди лечить буферы», и именно так разрешился кейс конкатенации в этом уроке.

### Соберите отчёт бенчмарки

Восстановите форматирование строк: имя в поле шириной 20, целое в поле шириной 12, дробное с двумя знаками в поле шириной 10.

```go
package main

import "fmt"

func main() {
        // Отчёт в духе go test -bench: столбцы выравнивает Printf.
        name := "BenchmarkConcat"
        n := 1000000
        nsPerOp := 245.5
        fmt.Printf("%___s\n", name)           // имя в поле шириной 20
        fmt.Printf("%___d итераций\n", n)     // целое в поле шириной 12
        fmt.Printf("%___f ns/op\n", nsPerOp)  // дробное: 2 знака, поле 10
        fmt.Println("4 allocs/op, 10 B/op")
}
```

Подсказка: Ширина — цифра между % и буквой типа (%20s, %12d); точность — точка и число знаков после неё (%10.2f).

Правильные ответы в пропусках: `20`, `12`, `10.2`

### Соберите каркас ручного замера

Соберите программу: функция-«операция» со счётчиком копирований, вызов в main, проверка ожидания и ранний выход — скелет, вокруг которого в настоящем бенчмарке встал бы цикл по b.N.

Правильный порядок строк:

1. `package main`
2. `import "fmt"`
3. `func copied(n int) int {`
4. `total := 0`
5. `s := ""`
6. `for i := 0; i < n; i++ {`
7. `total += len(s)`
8. `s += "x"`
9. `}`
10. `return total`
11. `}`
12. `func main() {`
13. `n := 12`
14. `c := copied(n)`
15. `want := 66`
16. `if c != want {`
17. `fmt.Println("FAIL: копирований", c)`
18. `return`
19. `}`
20. `fmt.Println("ok: 12 итераций, 66 копирований")`
21. `}`

### Шпаргалка: флаги go test -bench и API testing.B

| Инструмент | Что делает | Как выглядит | Осторожно |
| --- | --- | --- | --- |
| func BenchmarkXxx | бенчмарка: руль *testing.B вместо *testing.T | func BenchmarkConcat(b *testing.B) | файл *_test.go; имя с заглавной после Benchmark |
| b.N | число итераций, подобранное фреймворком | for i := 0; i < b.N; i++ { … } | не присваивайте его руками; Go 1.24+ — b.Loop() |
| go test -bench=. | запуск бенчмарок пакета (регулярка по имени) | go test -bench=Concat -run='^$' | -run='^$' отключает тесты, чтобы не мешали |
| ns/op | средняя стоимость одной итерации | BenchmarkConcat-8  245.3 ns/op | сравнивайте на одном железе; -8 — GOMAXPROCS |
| -benchmem | столбцы аллокаций в отчёте | go test -bench=. -benchmem | B/op и allocs/op — средние на итерацию |
| b.ReportAllocs | включить аллокации для одной бенчмарки | b.ReportAllocs() первой строкой | флаг -benchmem охватывает весь прогон |
| b.ResetTimer | обнулить таймер и счётчики аллокаций | setup(); b.ResetTimer(); цикл | ставить ПОСЛЕ подготовки и ДО цикла |
| b.StopTimer / StartTimer | заморозить и возобновить часы | b.StopTimer(); готовим; b.StartTimer() | не бесплатные: для копеечных операций шумят |
| -count / -benchtime | повторы и длительность подгона | go test -bench=. -count=5 -benchtime=2s | один прогон — анекдот; смотрите медиану |
| -cpuprofile / -memprofile | записать профиль CPU/памяти из бенчмарок | go test -bench=. -cpuprofile=cpu.out | профиль имеет смысл только под нагрузкой |
| go tool pprof | чтение профиля: top, list, -http | go tool pprof cpu.out → top5, list Concat | flat — в теле функции, cum — с вызываемыми |
| flame graph | стеки столбиками: ширина = доля времени | go tool pprof -http=:8080 cpu.out | ищите самый широкий столбик, который вы контролируете |

### Правила гигиены бенчмаркинга

- Подготовка — вне измеряемого окна: данные до цикла, ResetTimer после setup, StopTimer/StartTimer — только для per-итерационной подготовки дорогих операций.
- Один прогон — не вывод: гоняйте -count=5 и сравнивайте медиану; разброс между повторами — норма, а не сигнал переписывать код.
- Сравнивайте варианты на одном железе и в одном окружении: ns/op без паспорта goos/goarch/cpu — просто число; переносимое — только отношение вариант/вариант.
- Смотрите на allocs/op раньше, чем на ns/op: количество аллокаций называет виновника быстрее наносекунд и почти не зависит от шума машины.
- Не давайте компилятору выбросить работу: читайте результат измеряемой операции после цикла (_ = result) или переходите на b.Loop() в Go 1.24+.
- Никакой печати внутри измеряемого окна: fmt.Println в цикле стоит дороже всего остального и превращает отчёт в сказку.
- Профиль снимайте только под реальной нагрузкой: cpuprofile на спящей программе покажет рантайм-ожидания, а не вашу логику.
- Оптимизируйте самое широкое место профиля, а не то, что приятно переписывать: top5 и flame graph говорят, где сидит половина времени — начинайте оттуда.

### Что дальше: от «быстро ли» к «как это устроено»

Соберём урок в одну картину. Бенчмарка — функция func BenchmarkXxx(b *testing.B) в файле *_test.go: то же семейство, что тесты из шестнадцатого урока, только руль другой. Число итераций b.N подбирает фреймворк удвоениями до достаточной длительности, поэтому в отчёте оно меняется от запуска к запуску — и это честное поведение. Отчёт go test -bench читается по столбцам: подобранное N, ns/op — цена одной итерации; флаг -benchmem добавляет B/op и allocs/op, а ResetTimer после setup и пара StopTimer/StartTimer держат окно замера чистым. pprof профилирование раскладывает то же время по стекам: -cpuprofile и -memprofile при бенчмарках, runtime/pprof в обычных программах, чтение через top, list и flame graph. Кейс конкатенации прошёл весь путь: счётчик копирований показал квадратичность, -benchmem назвал виновником аллокации, strings.Builder свёл работу к линейной.

Тесты проверяют «правильно ли», бенчмарки — «быстро ли», а следующий вопрос звучит как «как программа видит саму себя в рантайме?». Восемнадцатый урок — рефлексия: reflect.TypeOf и reflect.ValueOf, как код осматривает чужие структуры во время выполнения, где это оправдано (энкодеры, драйверы баз, мапперы), и главное — цена, которую рефлексия берёт за гибкость. Цену вы теперь умеете измерять: напишите BenchmarkReflect и BenchmarkDirect рядом и сравните allocs/op — предсказываю, цифры вас неприятно удивят, и это лучший мотиватор выучить границы приёма.

До встречи на [странице курса](/courses/go-advanced/): а если понадобится освежить каркас проверок, вокруг которого встали сегодняшние бенчмарки, — вернитесь к [уроку про тестирование](/courses/go-advanced/lessons/16-testirovanie/) и перечитайте раздел про table driven tests: бенчмарки с prepare-данными собираются по той же схеме «данные отдельно, каркас один». А при чтении чужих профилей пригодится привычка из [урока про оборачивание ошибок](/courses/go-advanced/lessons/15-oshibki-wrapping/): сначала личность причины (errors.Is к логу, top к стеку), потом лечение.

### Карточки урока

- **func BenchmarkXxx(b *testing.B)** — Бенчмарка — функция в файле *_test.go с префиксом Benchmark и заглавным продолжением; единственный параметр *testing.B, возврата нет. Запускается go test -bench=<регулярка>; классическая форма тела — цикл for i := 0; i < b.N; i++. Подготовка данных — до цикла, результат после него стоит прочитать (_ = ...), чтобы компилятор не выбросил работу.
- **b.N и его подгонка** — b.N — число итераций, которое testing подбирает сам: стартует с малого и удваивает, пока прогон не станет достаточно долгим (по умолчанию ~1s, флаг -benchtime). Поэтому число итераций в отчёте меняется от запуска к запуску — это норма. Присваивать b.N руками нельзя; с Go 1.24 есть b.Loop(), снимающий и ручной счётчик, и риск мёртвого кода.
- **ns/op и столбцы отчёта** — BenchmarkConcat-8 4908816 245.3 ns/op: имя, -8 — GOMAXPROCS машины, число — подобранное b.N, ns/op — средняя стоимость одной итерации. Перед данными печатается паспорт goos/goarch/pkg/cpu: абсолютные наносекунды сравнимы только на одном железе. Разброс между прогонами лечат -count=5 и медианой (утилита benchstat).
- **-benchmem: B/op и allocs/op** — Флаг -benchmem (или b.ReportAllocs()) добавляет два столбца: B/op — средние байты аллокаций на итерацию, allocs/op — среднее число вызовов аллокатора на итерацию. Аллокации — главный подозреваемый торможений: они кормят GC и бьют по кэшу, поэтому allocs/op смотрите раньше ns/op. Оба счётчика сбрасываются вместе с таймером.
- **ResetTimer / StopTimer / StartTimer** — b.ResetTimer() обнуляет таймер и счётчики аллокаций — ставится после setup-подготовки и перед измеряемым циклом. b.StopTimer()/b.StartTimer() замораживают и возобновляют часы вокруг per-итерационной подготовки; вызовы не бесплатные, для копеечных операций дают шум. Идеал — подготовка целиком вне измеряемого окна.
- **pprof: снятие и чтение профилей** — Бенчмарки: go test -bench=. -cpuprofile=cpu.out (память — -memprofile). Обычные программы: runtime/pprof — StartCPUProfile/StopCPUProfile, WriteHeapProfile. Чтение: go tool pprof cpu.out → top5 (flat — в теле, cum — с вызываемыми), list Func — проценты по строкам, -http=:8080 → flame graph: ширина столбика = доля времени; оптимизируйте самый широкий свой столбик.

## Коротко: что запомнить

- Бенчмарка — функция func BenchmarkXxx(b *testing.B) в файле *_test.go рядом с кодом: то же семейство, что TestXxx, но руль измерения — testing.B, а не testing.T.
- Число итераций b.N подбирает фреймворк: малый старт и удвоения до достаточной длительности (-benchtime, по умолчанию 1s); меняющееся N в отчёте — норма, присваивать b.N руками нельзя.
- Классическая форма — for i := 0; i < b.N; i++ с чтением результата после цикла; с Go 1.24 есть b.Loop(), но b.N-форма остаётся универсальной классикой.
- go test -bench=. -run='^$' — канонический запуск без тестов; отчёт читается по столбцам: имя-GOMAXPROCS, подобранное N, ns/op; паспорт goos/goarch/cpu обязателен для сравнений.
- -benchmem добавляет B/op и allocs/op — средние байты и число аллокаций на итерацию; аллокации — главный подозреваемый торможений, их смотрите раньше наносекунд.
- ResetTimer после setup обнуляет таймер и счётчики аллокаций; StopTimer/StartTimer замораживают часы вокруг per-итерационной подготовки — но сами не бесплатные.
- Один прогон — анекдот: -count=5 и медиана; печать внутри измеряемого окна портит отчёт сильнее любой оптимизации.
- pprof профилирование: -cpuprofile/-memprofile у бенчмарок, runtime/pprof в программах; профиль имеет смысл только под реальной нагрузкой.
- Чтение профиля: top5 (flat/cum), list Func — проценты по строкам, flame graph через -http=:8080 — ширина столбика равна доле времени; начинайте с самого широкого своего столбика.
- Кейс урока: += в цикле копирует n(n-1)/2 байтов (квадратично), strings.Builder растёт буфером удвоением (линейно); -benchmem показывает это как 4 allocs/op против 1 — разрыв, который повторяется на любом железе.

## Домашнее задание

Часть первая — первые бенчмарки: создайте пакет с функцией Concat(parts []string) string на += и двумя Benchmark-функциями — BenchmarkConcat и BenchmarkBuilder (вариант на strings.Builder с WriteString). Запустите go test -bench=. -benchmem -run='^$' -count=5 и выпишите столбцы ns/op, B/op, allocs/op для обеих; посчитайте разрыв и объясните письменно, почему allocs/op отличается в четыре раза. Часть вторая — гигиена замера: добавьте в бенчмарку prepare-фазу с генерацией входных данных и проверьте на себе разницу: без b.ResetTimer() и с ним; затем заведите бенчмарку с per-итерационной подготовкой через b.StopTimer()/b.StartTimer() и сравните ns/op с чистым вариантом. Часть третья — профилирование: снимите go test -bench=Concat -cpuprofile=cpu.out, откройте go tool pprof cpu.out, выполните top5 и list Concat; найдите функцию с максимальным flat и объясните, почему это runtime.memmove или mallocgc, а не ваша строка кода. Часть четвёртая — счётчик: перенесите ручной счётчик копирований из урока на вход из 100 кусочков и убедитесь, что n(n-1)/2 сходится с 4950; затем замените += на срез с append и strings.Join и посчитайте копии снова. Комментарии в коде — по-русски.

## Вопросы и ответы по уроку

### Чем бенчмарка отличается от теста и как они уживаются в одном файле?

Оба живут в файлах *_test.go и запускаются go test, но тест — func TestXxx(t *testing.T) проверяет корректность и завершается вердиктом, а бенчмарка — func BenchmarkXxx(b *testing.B) измеряет скорость: фреймворк подбирает число итераций b.N и печатает ns/op. Обычный go test бенчмарки не запускает (нужен флаг -bench), а go test -bench всё равно выполняет тесты — их отключают фильтром -run='^$'. Рядом они уживаются естественно: один файл описывает и поведение, и стоимость.

### Почему ns/op меняется от запуска к запуску и как сравнивать честно?

Разброс дают планировщик, кэши процессора, соседние процессы и сама подгонка b.N — число итераций каждый раз новое. Честное сравнение: одно железо, один паспорт окружения (goos/goarch/cpu в шапке отчёта), несколько повторов (-count=5) и сравнение медиан или утилитой benchstat, которая скажет, значимо ли отличие. Абсолютные числа переносятся между машинами плохо — переносимы отношения вариант/вариант на одном прогоне.

### Зачем b.ResetTimer, если подготовка стоит до цикла?

Подготовка до цикла действительно не попадает в замер — таймер стартует с началом измеряемой фазы. Но ResetTimer нужен как дисциплина и страховка: при подгоночных прогонах и при любом переносе setup-кода (внутрь функции, после первого замера, между вариантами) дорогое одноразовое действие размазывается по итерациям и завышает ns/op. Вызов обнуляет и таймер, и счётчики аллокаций, поэтому B/op и allocs/op тоже остаются чистыми.

### Что выбрать: классический цикл по b.N или новый b.Loop()?

Классическая форма for i := 0; i < b.N; i++ работает во всех версиях Go, читается в любом старом коде и документации — с неё и начинают. b.Loop() появился в Go 1.24: сам сбрасывает таймер перед первым вызовом, сам подбирает итерации и мешает компилятору выбросить неиспользуемый результат, поэтому новый код на современных версиях может писать for b.Loop() { ... }. Семантика замера одинакова — это один и тот же контракт, просто без ручного счётчика.
