Тема 03

Память, GC и производительность

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

Что реально проверяют этой секцией

Три вещи. Первая — понимаешь ли ты цену своего кода: почему одна строчка fmt.Sprintf в горячем цикле дороже, чем кажется. Вторая — умеешь ли ты диагностировать, а не гадать: «сервис ест память» должно превращаться в последовательность конкретных команд, а не в «наверное, утечка горутин». Третья — знаешь ли ты границы: где GC уже не поможет, почему в языке со сборщиком мусора бывают утечки и почему «сначала измерь» — это не вежливая фраза, а рабочий процесс.

3.1Стек и куча, escape analysis

Чаще всего разговор о памяти начинают отсюда. Проверяют, понимаешь ли ты, что «выделить память» можно двумя способами, которые стоят совсем по-разному, и что выбирает между ними компилятор, а не ты.

Сначала — четыре слова, без которых дальше будет каша

Вся тема крутится вокруг одного практического вопроса: во что программе обходится каждое созданное значение? Ответ «в байты» неверный. Одни значения почти ничего не стоят и исчезают сами, другие оплачиваются дважды: сначала при создании, потом при уборке. Чтобы разобрать эту разницу, сначала договоримся о четырёх словах.

1. Аллокация — это выделение памяти под новое значение

Аллокацией (от англ. allocate, «выделить») называют момент, когда программа говорит: «мне нужен кусок памяти размером N байт, дай его». Речь именно о выдаче места, а не о «создании переменной» вообще. Слово важно потому, что аллокации считают: в отчёте бенчмарка колонка allocs/op показывает, сколько раз за одну операцию программа просила новый кусок памяти. Часто это число важнее наносекунд, потому что каждая аллокация в куче тянет за собой будущую работу сборщика мусора.

2. Указатель — переменная, в которой лежит не значение, а адрес

Обычная переменная хранит сами данные. Указатель (*T) хранит адрес другого кусочка памяти: «нужное лежит вон там». Если значение считать квартирой, то указатель будет бумажкой с её адресом. Скопировав бумажку, ты получил вторую бумажку, но квартира осталась одна. Подробно это разобрано в главе 1.1 темы «Go: язык», а здесь важно одно следствие: если у кого-то на руках есть адрес, память по этому адресу нельзя освобождать, иначе бумажка приведёт в пустоту. Из-за указателей вопрос «где хранить значение» и перестаёт быть простым.

3. Кадр функции — коробка с локальными переменными одного вызова

Вызванной функции нужно место под параметры, локальные переменные и адрес возврата. Этот блок памяти называется кадром вызова (англ. stack frame, в разговоре часто говорят «фрейм»). Размер кадра компилятор знает заранее: он же видит, сколько у функции локальных переменных и какого они типа. Кадр живёт ровно от входа в функцию до return: вызвали, и он появился; вернулись, и его нет. Кадры складываются друг на друга стопкой, отсюда и слово «стек».

4. Escape analysis — по-русски «анализ убегания»

Компилятор берёт каждую переменную и пытается доказать: «ни одна ссылка на неё не переживёт кадр, в котором она объявлена». Если доказал, переменная ложится прямо в кадр, то есть на стек. Если нет, говорят, что переменная «убегает» (escapes) и её приходится класть в кучу. Отсюда и название: анализ ищет, что именно убегает из кадра наружу. Компилятор занимается этим не ради красоты: без такого анализа в кучу пришлось бы класть всё подряд, и программа стала бы в разы медленнее. Устоявшегося русского термина нет, на собесе говорят «эскейп-анализ» или прямо «escape analysis».

Что сможешь объяснить после этой главы

Почему return &User{} стоит дороже, чем return User{}. Почему fmt.Println(x) может утащить x в кучу, а sum(vals) не утащит. И что отвечать на вопрос «как заставить компилятор положить это на стек»: «директивы нет, но код можно переписать так, чтобы ссылка не убегала».

Две области памяти и почему они разные

У каждой горутины свой приватный стек, та самая стопка кадров. Он линейный: регистр-указатель SP (stack pointer) показывает на вершину стопки, вызов функции сдвигает его на размер кадра, возврат сдвигает обратно. Учитывать занятое, искать свободное место, что-то освобождать не нужно: кадр просто перестаёт существовать. Поэтому стек и «дешёвый»: кадр выделяется и освобождается одной арифметической операцией над регистром, а она уже вшита в пролог и эпилог функции, которые компилятор генерирует всё равно. Стек не «быстрая память», процессор ничего про него не знает; бесплатно тут освобождение.

Куча (англ. heap) у процесса одна, общая на всех. Сюда попадает всё, что должно пережить свой кадр: данные за указателями, объекты, на которые смотрят из нескольких горутин, всё, чей размер неизвестен на этапе компиляции. Стопки здесь нет, поэтому память приходится учитывать: кто-то должен помнить, какие куски заняты, находить свободное место под запрос и потом возвращать освободившееся. Этим занимается аллокатор, а вопрос «что уже не нужно» решает сборщик мусора: сама программа в Go память не освобождает.

Аллокатор Go устроен по образцу TCMalloc и похож на трёхуровневую систему складов. У каждого P (процессора планировщика) есть личный кэш mcache, из него берут без всяких блокировок: это быстрый путь, десятки наносекунд. Если кэш опустел, аллокатор идёт в mcentral, общий склад на один размерный класс, а если пуст и он, то в mheap, который берёт у ОС целые страницы. Размерных классов 68, это фиксированные размеры (8, 16, 24, 32 байта и так далее до 32 КБ), и запрос округляется вверх до ближайшего: на 20 байт выдадут блок в 24, зато поиск свободного места сводится к «взять первый блок из списка нужного класса». Всё, что больше 32 КБ, выдаётся отдельно, напрямую из mheap. Внутри аллокатор оперирует спанами (mspan), так называют непрерывный кусок страниц одного размерного класса. Отдельно есть tiny-аллокатор: объекты меньше 16 байт без указателей внутри упаковываются по несколько штук в один 16-байтовый блок.

Но дороже всего в куче обходится не сама аллокация. Каждый объект в куче потом кто-то должен обойти, пометить как живой и в конце подмести. Аллокация в стеке не создаёт работы для сборщика вообще, а аллокация в куче создаёт её дважды: на маркировке и на sweep. Из этого складывается вся дальнейшая арифметика темы. Стек бесплатен на выходе, а куча платит трижды: при выдаче места, при обходе сборщиком и при уборке.

Что поменялось в аллокаторе в свежих версиях
  • Go 1.27: мелкие аллокации дешевле, до 30 %. Раньше mallocgc был одной универсальной функцией: по запрошенному размеру она в рантайме вычисляла размерный класс, проверяла флаги, выбирала ветку. Теперь компилятор для объектов до 80 байт подставляет процедуру, специализированную под конкретный размерный класс: все эти вычисления и ветвления сделаны заранее, на этапе компиляции, и в рантайме остаётся почти только «взять блок из списка». Платим за это размером бинаря, он вырастает примерно на 60 КБ. Отключается GOEXPERIMENT=nosizespecializedmalloc.
  • Go 1.26: база кучи рандомизируется на 64-битных платформах, то есть куча при каждом запуске начинается с нового адреса. Сделано это ради безопасности, скорость тут ни при чём: эксплойту, который полагается на предсказуемые адреса объектов, становится сильно тяжелее. Есть побочный эффект: адреса в двух запусках больше не совпадают, так что логи вида 0xc000123456 между запусками не сравнишь. Отключается GOEXPERIMENT=norandomizedheapbase64.
Стек горутины свой у каждой горутины SP свободная часть стека parse() buf [64]byte n int, p *T handle() main() выделить кадр = SP -= const освободить кадр = SP += const &T{} убегает на кучу Куча одна на процесс, обслуживается GC живые объекты — GC их обходит и метит мусор — вернётся в spans на фазе sweep путь аллокации mcache mcentral mheap быстрый путь через mcache — без блокировок, десятки нс; но каждый объект добавляет работы сборщику. Стек дешевле не потому, что «эта память быстрее», а потому что освобождение бесплатно: кадр исчезает по return. У кучи цена размазана: поиск места при аллокации + обход объекта сборщиком + уборка на фазе sweep.
Стек и куча. Слева линейная область: всё в ней живёт ровно столько, сколько кадр. Справа общая область с учётом, аллокатором и сборщиком. Стрелка между ними и есть escape analysis.

Escape analysis: что это на самом деле

Escape analysis, статический анализ компилятора (cmd/compile/internal/escape), для каждой переменной пытается доказать уже знакомое: «ни одна ссылка на неё не переживёт текущий кадр». Если удалось, переменная кладётся на стек, если нет, уходит в кучу. «Статический» здесь значит «на этапе компиляции, без запуска программы»: компилятор рассуждает о коде, а не наблюдает за ним.

Обрати внимание на асимметрию. Анализ консервативный: при малейшем сомнении он выбирает заведомо безопасный вариант, а безопасна тут куча. Объект в куче сработает всегда, просто дороже. До объекта на стеке кто-то может дотянуться после выхода из функции, и это уже порча памяти. Поэтому любая неопределённость (вызов через интерфейс или функцию-значение) решается в пользу кучи. Компилятор не спрашивает «убежит ли?», он спрашивает «могу ли я доказать, что не убежит?», и молчание засчитывается как «убежит».

Анализ межпроцедурный, но не «глобальный». Для каждой функции компилятор считает сводку по параметрам: какой параметр «утекает» в возвращаемое значение, какой «утекает» в кучу, какой не утекает вообще. Эти сводки пишутся в export data пакета, поэтому анализ работает и через границы пакетов, и по той же причине в выводе -m ты видишь строки вида leaking param: name.

Причина побегаПримерПочему
Возврат указателяreturn &User{}ссылка переживает кадр по определению
Запись указателя в кучуglobal = &x, obj.Field = &x, s = append(s, &x)объект в куче не может ссылаться на стек
Упаковка в интерфейсfmt.Println(x), var a any = xинтерфейс хранит указатель на данные; компилятор чаще всего не видит конкретного использования
Захват замыканием, которое переживает функциюgo func(){ use(x) }(), return func(){...}переменная переезжает в объект-замыкание в куче
Отправка в каналch <- &xчитатель — другая горутина с другим стеком
Неизвестный на компиляции размерmake([]byte, n), где n — переменнаяв кадре нельзя зарезервировать место под неизвестное число байт
Слишком большой объектvar buf [20 << 20]byte, make([]byte, 1<<20)лимиты компилятора: 128 КБ для явных переменных (до Go 1.24 было 10 МБ), 64 КБ для неявных (new, &T{}, литералы)
Непрозрачный вызоввызов через интерфейс или переменную-функциютела не видно — считаем, что аргументы утекают
Два мифа, на которых ловят
  • «new выделяет в куче, а литерал на стеке». Нет. new(T) и &T{} для компилятора одно и то же; оба могут остаться на стеке, если указатель не убегает.
  • «Большой объект всегда в куче». Не «большой», а «больше лимита компилятора»: 64 КБ для неявных аллокаций и 128 КБ для явно объявленных переменных. Массив на 60 КБ прекрасно живёт на стеке, просто стек ради него вырастет.

Как посмотреть решения компилятора

$ go build -gcflags="-m" ./...          # решения по своим пакетам
$ go build -gcflags="-m -m" ./...       # второй уровень: с объяснением цепочки
$ go build -gcflags="all=-m" ./...      # включая зависимости
$ go build -gcflags="-m -l" ./...       # -l отключает инлайн: видно решения "как есть"
$ go test  -gcflags="-m" -bench=. ./pkg # то же для тестов и бенчмарков
package main

import (
    "fmt"
    "os"
)

type User struct {
    Name string
    Age  int
}

func newUser(name string) *User {
    return &User{Name: name}
}

func sum(vals []int) int {
    s := 0
    for _, v := range vals { s += v }
    return s
}

func readSize() int { return len(os.Args) * 1024 }   // размер известен только в рантайме

func main() {
    u := newUser("bob")
    fmt.Println(u.Age)
    buf := make([]byte, 1024)
    _ = buf
    n := readSize()
    big := make([]byte, n)
    _ = big
    _ = sum([]int{1, 2, 3})
}

Вот что компилятор печатает на самом деле. Вывод снят командой go build -gcflags="-m" . на go1.27, идёт в stderr и приведён целиком:

$ go build -gcflags="-m" .
# esc
./m.go:13:6: can inline newUser
./m.go:17:6: can inline sum
./m.go:23:6: can inline readSize
./m.go:26:14: inlining call to newUser
./m.go:27:13: inlining call to fmt.Println
./m.go:30:15: inlining call to readSize
./m.go:33:9: inlining call to sum
./m.go:13:14: leaking param: name
./m.go:14:9: &User{...} escapes to heap
./m.go:17:10: vals does not escape
./m.go:26:14: &User{...} does not escape
./m.go:27:13: ... argument does not escape
./m.go:27:15: u.Age escapes to heap
./m.go:28:13: make([]byte, 1024) does not escape
./m.go:31:13: make([]byte, n) does not escape
./m.go:33:15: []int{...} does not escape

Три строки здесь ломают привычные ожидания, их разберём отдельно.

  • Про &User{} написано и «escapes to heap», и «does not escape». Первая строка (14:9) относится к телу newUser, где указатель уходит в возврат. Вторая (26:14) описывает то же выражение после подстановки функции в main: там уже видно, что u дальше вызова не живёт, и объект остаётся на стеке. Итог решает вторая строка, поэтому читай весь вывод, а не первую попавшуюся строку про нужное выражение. А -l нужен ровно для обратного: посмотреть, что было бы без инлайна.
  • u.Age escapes to heap: убегает не структура, а int, который упаковывается в any для fmt.Println. В реальном коде это самый частый «неожиданный» побег.
  • make([]byte, n) does not escape и есть ловушка: такое сообщение escape analysis не означает «на стеке». Размер неизвестен на компиляции, поэтому на стеке есть только запасной буфер в 32 байта (с Go 1.25), а всё, что больше, берётся в куче. Бенчмарк на go1.27 (size() помечена //go:noinline) показывает разницу прямо: make([]byte, 64) даёт 0 B/op и 0 allocs/op, а make([]byte, size(64)) даёт 64 B/op и 1 allocs/op.
Строка в выводеЧто означает
escapes to heapвыражение (обычно &T{}, new, make) аллоцируется в куче
moved to heap: xобычная локальная переменная перенесена в кучу — на неё взяли адрес, который убегает
does not escapeуказатель наружу не уходит. Обычно означает стек — но при неизвестном на компиляции размере (make([]byte, n)) больше 32 байт память всё равно берётся в куче
leaking param: pпараметр утекает наружу (например, попадает в возвращаемое значение или в глобал)
leaking param content: pутекает не сам указатель, а то, на что он указывает
parameter p leaks to ~r0 for f with derefs=0вывод -m -m: параметр уходит в первый результат
Дисциплина чтения -m

-gcflags="-m" без all= печатает только про пакеты из аргумента. И ещё: картину меняет инлайнинг, когда компилятор вместо настоящего вызова подставляет тело функции прямо в место вызова (подробно в главе 3.4). Функция, чей аргумент «утекал», после подстановки может оказаться полностью на стеке. Поэтому смотри на итоговый вывод без -l, а -l включай, только чтобы понять, что происходит без инлайна.

Можно ли повлиять на решение

Директивы «положи это на стек» в Go нет и не будет, так решили сознательно: место аллокации считается деталью реализации. Влиять можно только формой кода.

// убегает: возвращаем указатель
func Parse(b []byte) (*Msg, error) {
    m := &Msg{}
    if err := m.decode(b); err != nil {
        return nil, err
    }
    return m, nil
}

// убегает: строка-результат уходит вызывающему (0–99 Itoa берёт из таблицы)
func Format(v int) string {
    return strconv.Itoa(v)
}

// убегает: интерфейс
log.Printf("id=%d", id)
// не убегает: пишем в буфер вызывающего
func Parse(b []byte, m *Msg) error {
    return m.decode(b)
}

// не убегает: дописываем в чужой буфер
func Format(dst []byte, v int) []byte {
    return strconv.AppendInt(dst, int64(v), 10)
}

// дешевле: на выключенном уровне аргументы не боксятся
if lg.Enabled(ctx, slog.LevelDebug) {
    lg.Debug("msg", "id", id)
}
  • Главный приём: отдавать буфер снаружи, то есть func(dst []byte, ...) []byte вместо func(...) []byte. Так устроены strconv.Append*, time.Time.AppendFormat, fmt.Appendf (Go 1.19).
  • Избегать any в горячем пути. Когда кладёшь значение в any, происходит боксинг (англ. boxing, «упаковка»): интерфейс хранит пару «тип + указатель на данные», поэтому само значение должно где-то лежать, и если оно убегает, то уезжает в кучу. На мелочи компилятор экономит (пустые структуры, маленькие целые берутся из готовой таблицы рантайма), но в общем случае каждый any-аргумент становится кандидатом в аллокацию. Дженерики здесь помогают: параметр типа не превращается в интерфейс для типов с одинаковой «GC-формой».
  • Не брать адрес там, где не нужно: метод с указательным receiver у локальной переменной может утащить её в кучу, если сам метод «пропускает» receiver наружу.
  • //go:noescape придуман не для обычного кода: это обещание компилятору про ассемблерную функцию (объявление без тела), что она не сохраняет переданные указатели. Соврёшь, и получишь порчу памяти.
  • sync.Pool не отменяет побег: объект всё равно в куче, просто он переиспользуется и не создаёт нового мусора.

Возврат структуры: по значению или по указателю

Здесь «зависит» и есть точный ответ, потому что в разные стороны тянут три силы.

  1. Копирование. Возврат по значению копирует структуру. Но с Go 1.17 на amd64 (с Go 1.18 и на arm64) работает register-based ABI: на amd64 до девяти целочисленных слов и пятнадцати float-регистров (на arm64 по шестнадцать) передаются в регистрах, без записи в память вообще. Структура на 2–4 поля возвращается практически бесплатно.
  2. Аллокация. Возврат указателя почти всегда означает escapes to heap: mallocgc плюс будущая работа сборщика. Счёт идёт на десятки наносекунд плюс амортизированная стоимость GC.
  3. Работа GC потом. []T из структур без указателей сборщик не сканирует вообще (спан помечен как noscan). []*T на миллион элементов означает миллион объектов, которые надо обходить каждый цикл.
Практическое правило

Маленькую структуру (примерно до 4–6 слов, то есть 32–48 байт), которую не надо мутировать, возвращай по значению. Большую, мутируемую или обязанную быть общей возвращай по указателю. Если у типа уже есть методы с указательным receiver, возвращай указатель, иначе получишь несогласованный API. Обычно же разница тонет в работе самой функции: в горячих путях измеряй, в остальных выбирай по читаемости.

Стек горутины: как он растёт

Горутина стартует с маленьким стеком: stackMin = 2 КБ. С Go 1.19 рантайм умнее: при сканировании стеков в GC он считает, сколько стека в среднем занято у всех горутин, и новым сразу выделяет столько, чтобы не платить за первые расширения.

Пролог почти каждой функции (кроме крошечных листовых и помеченных //go:nosplit) сравнивает SP с полем stackguard0 в структуре g. Если места не хватает, вызывается runtime.morestack, а тот зовёт newstack:

  1. выделяется новый стек как минимум вдвое больше старого (удваивается, пока не влезет новый кадр);
  2. содержимое копируется одним memmove;
  3. рантайм проходит по кадрам и чинит все указатели, которые указывали внутрь старого стека, а где они лежат, узнаёт по картам указателей (stack maps), которые компилятор сгенерировал для каждой точки вызова;
  4. старый стек возвращается в пул.

Обратную операцию, shrinkstack, рантайм выполняет во время GC: если горутина использует меньше четверти своего стека, он ужимается вдвое. Потолок задаёт maxstacksize: 1 ГБ на 64-битных платформах (250 МБ на 32-битных), настраивается через debug.SetMaxStack. Превышение даёт fatal error: stack overflow, и это не паника: recover её не поймает, процесс умирает.

Стек не расширяется на месте — он переезжает целиком старый стек 2 КБ main handle parse нет места → пролог зовёт runtime.morestack указатель внутрь стека memmove: кадры копируются как есть и рантайм правит каждый указатель, который смотрел внутрь старого стека новый стек 4 КБ main handle parse свободно — места вдвое больше адрес переписан на новый Почему так можно в Go: компилятор строит точную карту указателей для каждого кадра, поэтому рантайм находит и правит все ссылки внутрь стека. В C указатель неотличим от целого числа — переместить стек нельзя, отсюда фиксированные 8 МБ на поток.
Copy stack. Чтобы расширить стек, рантайм выделяет новый блок, делает memmove и правит указатели по stack maps. Цена амортизируется удвоением, поэтому «дорогих» переездов за жизнь горутины единицы.
Связь, которую редко называют вслух

Кроме оптимизации, у escape analysis есть вторая роль: на нём держится корректность перемещаемых стеков. Рантайм при переезде чинит указатели только внутри стеков и регистров. Если бы указатель на стековую переменную мог оказаться в куче, чинить его было бы негде, и после переезда он смотрел бы в освобождённую память. Поэтому всё, до чего можно добраться из кучи, обязано быть в куче: анализ не «хотел бы», а должен вытолкнуть такую переменную. Скажешь это на собесе, и сразу станет видно, что ты понимаешь механизм, а не выучил список правил.

Вопросы

6
Суть: стек — приватная линейная область горутины, выделение = сдвиг регистра и освобождение бесплатное; куча — общая область с аллокатором и сборщиком, где каждый объект оплачивается дважды: при выделении и при последующем обходе GC.

Стек

У каждой горутины свой стек (стартует с 2 КБ в Go 1.4+ и растёт удвоением до maxstacksize, по умолчанию 1 ГБ на 64-битных). Память под кадр функции резервируется в прологе одним вычитанием из указателя стека, освобождается в эпилоге одним сложением. Списков свободных блоков, поиска подходящего размера и блокировок здесь нет: стек не разделяется между горутинами. Данные кадра почти наверняка горячие в L1: адреса, к которым только что обращались.

Куча

Куча одна на процесс и устроена как TCMalloc. Путь аллокации: mcache (локальный кэш текущего P, без блокировок) → mcentral (общий на размерный класс, наборы спанов в нём lock-free) → mheap (страницы, глобальный лок) → mmap у ОС. Объекты раскладываются по 68 размерным классам до 32 КБ, всё крупнее идёт как large object напрямую из mheap. Плюс tiny-аллокатор: несколько объектов меньше 16 байт без указателей упаковываются в один блок. Свежая деталь на добивку: с Go 1.27 мелкие аллокации (до 80 байт) идут через процедуры, специализированные по размерному классу на этапе компиляции, и это экономит до 30 % на самом горячем пути аллокатора.

Что где оказывается

  • Всегда на стеке: аргументы, возвращаемые значения (часто вообще в регистрах благодаря register ABI с Go 1.17) и локальные переменные, если компилятор доказал их «неутечку». Параметр, чей адрес убегает, компилятор переносит в кучу, как и локальную переменную.
  • Всегда в куче: глобальные переменные (технически они в сегменте данных, но сканируются как корни), всё, на что ссылаются из кучи, всё непрозрачного размера, кроме мелочи: с Go 1.25 слайс переменного размера до 32 байт остаётся на стеке.
  • Решает компилятор: всё остальное, этим и занимается escape analysis. При этом new() и &T{} не гарантируют кучу, а литерал структуры не гарантирует стек.

Почему «дешевле» — по пунктам

  1. Выделение: одна арифметическая операция против mallocgc с поиском размерного класса, проверкой кэша и (иногда) с уходом в mcentral. С Go 1.27 этот разрыв немного сократился: для объектов до 80 байт компилятор подставляет процедуру, специализированную под конкретный размерный класс, и мелкая аллокация дешевеет до 30 % (ценой ~60 КБ размера бинаря).
  2. Освобождение: бесплатно и мгновенно, против отложенной работы sweeper-а.
  3. Работа GC: стек сканируется один раз за цикл целиком, объекты в куче обходятся по графу.
  4. Локальность: кадры лежат подряд, объекты в куче разбросаны, отсюда больше промахов кэша.
Фраза, после которой вопрос закрывается

«Разница не столько в скорости самой аллокации: mallocgc из mcache тоже быстрый, десятки наносекунд. Разница в том, что объект в куче создаёт работу в будущем: его надо промаркировать в следующем цикле GC и подмести. Стековая переменная для сборщика вообще ничего не создаёт. Поэтому в горячем пути смотрят не на время аллокации, а на allocs/op

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

Формально компилятор строит граф «кто на кого может ссылаться»: вершины — переменные и аллокации, рёбра — присваивания с весом derefs (число разыменований минус взятий адреса). Затем ищет пути, по которым адрес попадает в кучу или переживает свою переменную. Для каждой функции получается сводка по параметрам, которая уезжает в export data пакета, поэтому анализ работает и через границы пакетов. Отсюда строки вида leaking param: name в выводе -m.

Правила побега (в порядке частоты)

  1. Возврат указателя на локальную переменную, например return &User{}. Ссылка по определению живёт дольше кадра.
  2. Запись указателя в объект, который сам в куче: global = &x, s = append(s, &x), obj.F = &x. Рантайм держит инвариант: из кучи нельзя ссылаться на стек, иначе перемещение стека всё сломает.
  3. Упаковка в интерфейс: fmt.Println(x), var a any = x. Интерфейс хранит указатель на данные, а метод вызывается непрозрачно, значит, компилятор обязан считать, что данные утекают. Исключение бывает, когда компилятор девиртуализует вызов и видит тело.
  4. Захват замыканием, которое переживает функцию, как в go func(){ use(x) }() или return func(){...}. Объект-замыкание уезжает в кучу; мелкие неизменяемые переменные копируются в него, а те, что замыкание меняет, переезжают в кучу отдельно.
  5. Отправка в канал (ch <- &x): читатель сидит на другом стеке.
  6. Неизвестный на компиляции размер: make([]byte, n) с переменной n, make(map[K]V, n). В кадре нельзя зарезервировать «сколько-нибудь», поэтому на стеке есть только запас на мелкий случай: 32 байта у слайса (с Go 1.25), одна группа на 8 элементов у мапы.
  7. Слишком большой объект. У компилятора есть лимиты: 128 КБ (до Go 1.24 было 10 МБ) для явных локальных переменных и 64 КБ для неявных аллокаций (new, &T{}, литералы, make с константой).
  8. Непрозрачный вызов через переменную-функцию или интерфейс: тела не видно, поэтому считаем, что параметры утекают. Рекурсия к этому не относится: рекурсивные функции компилятор анализирует вместе и видит их тела, так что параметры вполне могут не утекать.
func a() *int   { x := 1; return &x }        // moved to heap: x
func b()        { x := 1; sink = &x }        // moved to heap: x
func c(v int)   { fmt.Println(v) }            // v escapes to heap (интерфейс)
func d() func() { x := 1; return func(){ x++ } } // moved to heap: x + func literal escapes to heap
func e(n int)   { _ = make([]byte, n) }       // does not escape: до 32 байт на стеке (Go 1.25+), больше — куча
func f()        { _ = make([]byte, 4096) }    // does not escape: константа, влезает в кадр
func g(p *T)    { p.X = 1 }                   // p does not escape: только читаем/пишем поля

// go build -gcflags="-m -l", вывод go1.27 дословно:
//   ./m.go:9:19: moved to heap: x
//   ./m.go:10:19: moved to heap: x
//   ./m.go:11:30: ... argument does not escape
//   ./m.go:11:31: v escapes to heap
//   ./m.go:12:19: moved to heap: x
//   ./m.go:12:34: func literal escapes to heap
//   ./m.go:13:27: make([]byte, n) does not escape
//   ./m.go:14:27: make([]byte, 4096) does not escape
//   ./m.go:15:8: p does not escape
//
// Про e(): «does not escape» тут значит только «указатель наружу не
// уходит». Размер n на компиляции неизвестен, кадр под него заранее
// не зарезервируешь. С Go 1.25 компилятор держит на стеке буфер в 32 байта
// и идёт в кучу, только если n больше. Целиком на стеке живёт f() с константой.
// Проверено на go1.27: e(16) — 0 аллокаций, e(64) — одна.
Три подвоха

«Указатель = куча». Нет: func g(p *T) выше не вызывает аллокации, вызывающий может держать T на своём стеке. Передать вниз по стеку указатель на стековую переменную в Go совершенно нормально.

«Escape analysis нужен ради производительности». Ради неё тоже, но ещё это условие корректности перемещаемых стеков: если бы указатель на стек мог осесть в куче, при morestack его некому было бы починить.

«Побеги вредны». Сами по себе нет. Плохо, когда побег сидит в горячем цикле и его можно убрать без ущерба для читаемости. Один &User{} на запрос никого не убьёт.

Суть: -gcflags="-m" печатает решения escape analysis и инлайнера; второй -m добавляет объяснение цепочки; -l выключает инлайн, чтобы видеть картину «как написано».
$ go build -gcflags="-m" ./...            # свои пакеты
$ go build -gcflags="-m -m" ./...         # с цепочкой причин
$ go build -gcflags="all=-m" ./...        # включая зависимости
$ go build -gcflags="-m -l" ./pkg         # без инлайна
$ go test -gcflags="-m" -bench=. ./pkg    # то же для бенчей
$ go build -gcflags="-m" ./... 2>&1 | grep 'escapes to heap' | sort | uniq -c | sort -rn

Что читать в выводе

СтрокаСмысл
escapes to heapвыражение (&T{}, new, make) аллоцируется в куче
moved to heap: xобычная локальная переменная перенесена в кучу
does not escapeобычно остаётся на стеке — то, чего мы хотим; но слайс переменного размера больше 32 байт всё равно уйдёт в кучу
leaking param: pпараметр утекает наружу функции (в возврат или в кучу)
leaking param content: pутекает не сам указатель, а то, на что он указывает
can inline f / inlining call to fрешения инлайнера — тут же

Чем добить

Одного -m мало: он показывает решения, а не стоимость. В пару к нему берут:

  • go test -bench=. -benchmem покажет allocs/op и B/op, то есть сколько это стоит на самом деле.
  • go build -gcflags="-m=2" эквивалентен -m -m.
  • -gcflags="-d=ssa/check_bce/debug=1" найдёт, где остались проверки границ (bounds check).
  • go tool pprof -alloc_objects ответит, кто реально аллоцирует в проде, а не в теории.
Дисциплина

Не читай -m по всему проекту: там тысячи строк и 95% из них к делу не относятся. Порядок такой: сначала профиль (-alloc_objects) находит горячую функцию, потом -m по одному пакету объясняет, почему она аллоцирует. Начать с -m и сразу оптимизировать значит оптимизировать вслепую.

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

Приёмы, которые реально работают

  1. Отдавать буфер снаружи. func F(dst []byte, ...) []byte вместо func F(...) []byte. Так устроены strconv.AppendInt, time.Time.AppendFormat, fmt.Appendf (Go 1.19).
  2. Принимать указатель на результат вместо возврата указателя: func Parse(b []byte, out *Msg) error.
  3. Не пропускать значение через any в горячем пути: fmt.Sprintf боксит каждый аргумент. Вместо него бери strconv, strings.Builder, дженерики (для типов с одинаковой GC-формой инстанцирование не создаёт интерфейс).
  4. Ограничить область жизни: не сохранять указатель в глобальную структуру «на всякий случай», не передавать в горутину то, что можно передать копией.
  5. Константные размеры: make([]byte, 4096) остаётся на стеке, а make([]byte, n) больше 32 байт нет. Иногда помогает верхняя граница: var buf [256]byte; b := buf[:n] при n <= 256.

Чего делать не надо

  • Для оптимизации Go-кода //go:noescape не годится. Это обещание компилятору про ассемблерную функцию (объявленную без тела), что она не сохраняет переданные указатели. Если соврать, при перемещении стека память испортится.
  • sync.Pool не отменяет побег: объект по-прежнему в куче, он просто переиспользуется.
  • unsafe ради «оставить на стеке» почти всегда хуже аллокации, которую ты пытался убрать.
Почему директивы нет

Если бы существовал //go:stackalloc, компилятор был бы обязан проверять, что программист не соврал, а это тот же самый escape analysis. Иначе остаётся только доверять программисту, то есть открыть дыру в безопасности памяти. Команда Go выбрала третий путь: улучшать сам анализ. За последние релизы он научился, например, оставлять на стеке мелкие слайсы, размер которых известен только в рантайме, и часть случаев с девиртуализацией интерфейсов.

Суть: «зависит» здесь точный ответ. По значению — копия, но без аллокации и без работы GC. По указателю — без копии, но почти всегда с аллокацией в куче и обходом сборщиком. Точка перелома — размер структуры и то, что с ней делают дальше.

Три силы

  1. Стоимость копии. С Go 1.17 на amd64 (с Go 1.18 и на arm64) работает register-based ABI: на amd64 9 целочисленных регистров и 15 float-регистров под аргументы и результаты, на arm64 по 16. Структура из 2–4 машинных слов возвращается вообще без записи в память.
  2. Стоимость аллокации. Возврат *T из функции почти всегда заканчивается строкой escapes to heap, а за ней стоят mallocgc и будущая работа сборщика.
  3. Стоимость для GC потом. Слайс []T из структур без указателей помечен как noscan и не сканируется вообще. []*T на миллион элементов даёт сборщику миллион объектов, которые он обходит каждый цикл. Разница тут на порядки, и она важнее копий.
СитуацияВыборПочему
Структура до ~4–6 слов, не мутируетсяпо значениюкопия в регистрах, ноль аллокаций
Крупная структура (десятки полей, массивы внутри)по указателюкопия дороже аллокации
Нужна мутация, видимая вызывающемууказательиначе меняешь копию
Объект разделяется между горутинамиуказателькопия разъедется
Хранится миллионами в слайсезначениеодин объект вместо N, noscan, локальность
Внутри есть sync.Mutex, sync.WaitGroup, atomic.*указателькопировать нельзя, go vet ругнётся
У типа уже есть методы с указательным receiverуказательконсистентность API важнее наносекунд
type Point struct {
    X, Y float64
}   // 16 байт, noscan

// хорошо: возврат в регистрах, аллокаций 0
func Mid(a, b Point) Point { return Point{(a.X+b.X)/2, (a.Y+b.Y)/2} }

// плохо в горячем пути: что ни вызов, то mallocgc и работа GC
func MidPtr(a, b *Point) *Point { return &Point{...} }

// а ещё важнее, как хранить:
pts := make([]Point,  1_000_000)  // 1 объект, спан noscan, GC его не обходит
ptr := make([]*Point, 1_000_000)  // 1_000_001 объект, каждый цикл GC обходит миллион
Как на этом ловят

Заготовленный ответ «указатели всегда быстрее, потому что не копируют» действует как красная тряпка. Дальше спросят: «а сколько стоит аллокация?», «а что будет с GC, если таких объектов миллион?», «а если структура 16 байт, что дешевле: скопировать 16 байт или сходить в mallocgc?». В правильном ответе звучат слова «замерил» и -benchmem: в 90% функций разница тонет в том, что функция делает по существу.

Суть: стек начинается с 2 КБ и при нехватке места копируется целиком в новый вдвое больший блок, а рантайм правит все указатели, смотревшие внутрь старого стека. Возможно это потому, что компилятор строит точные карты указателей (stack maps) для каждого кадра.

Механика

  1. Пролог почти каждой функции сравнивает SP с полем stackguard0 в структуре g. Проверка занимает пару инструкций, ветка предсказуемая.
  2. Места не хватило: вызывается runtime.morestack, горутина попадает в newstack.
  3. Выделяется новый блок как минимум вдвое большего размера (стеки берутся из пулов по размерам: 2, 4, 8, 16 КБ и дальше из mheap).
  4. memmove копирует старые кадры в новый блок.
  5. Рантайм проходит по кадрам, по stack maps находит все слоты-указатели и прибавляет дельту к тем, что указывали внутрь старого стека. Плюс правит указатели в defer-записях и в g.
  6. Старый блок освобождается, выполнение продолжается с того же места.

Сжатие тоже есть: если при сканировании стека в фазе маркировки занято меньше четверти, рантайм может скопировать стек в блок вдвое меньше (shrinkstack). Верхний предел задаёт runtime/debug.SetMaxStack, по умолчанию 1 ГБ на 64 битах; превысишь его и получишь fatal error: stack overflow (это не паника, её не поймать).

Почему это возможно в Go и невозможно в C

В Go указатели точно типизированы, а адресной арифметики нет. Компилятор для каждой точки вызова знает, какие слоты кадра содержат указатели, и записывает это в метаданные, так что рантайм может найти и переписать каждую ссылку. В C указатель неотличим от long: «починить» его невозможно, поэтому размер стека фиксируется заранее (типично 8 МБ на поток), а переместить его нельзя. Отсюда прямое следствие: миллион горутин по 2 КБ реален, а миллион потоков по 8 МБ нет.

История и грабли, о которых приятно знать

До Go 1.3 использовались сегментированные стеки (split stacks): вместо копирования подцеплялся новый сегмент. Отсюда рождался «hot split»: цикл, вызывающий функцию ровно на границе сегмента, на каждой итерации выделял и освобождал сегмент, и производительность падала в разы. Copy stack эту проблему убрал: удвоение амортизирует цену, за жизнь горутины переездов единицы. Практический вывод для кода: глубокая рекурсия и жирные локальные массивы в рекурсивных функциях = лишние копирования стека; и //go:nosplit в обычном коде ни к чему: проверка в прологе снимается, и цепочке таких функций остаётся запас около 800 байт. Молча вылезти за него не выйдет: линкер проверяет цепочки и падает с «nosplit stack over … byte limit».

3.2Сборщик мусора

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

Сначала — зачем вообще сборщик мусора и почему нельзя просто «остановиться и посчитать»

Задача сборщика умещается в одну строку: найти в куче память, до которой программа уже никогда не дотянется, и вернуть её в оборот. Трудно другое: сделать это, не останавливая программу.

Альтернатив ровно две, и обе Go отверг. Первая — освобождать вручную, как в C: каждый malloc обязан иметь парный free. Работает, но человек ошибается двумя способами, и оба тяжёлые. Забыл освободить — утечка памяти. Освободил дважды или освободил то, на что ещё смотрит чей-то указатель, получил use-after-free, один из главных источников уязвимостей за последние тридцать лет. Вторая — считать ссылки на каждый объект, как в Python или Swift: счётчик упал до нуля, объект освобождается сразу. Проще, но платит на каждом присваивании указателя, требует атомарных операций в многопоточной программе (а Go весь построен на горутинах) и в принципе не умеет собирать циклы: два объекта, ссылающиеся друг на друга, никогда не обнулят счётчики.

Go выбрал третий путь: трассирующий сборщик. Он не ведёт учёт постоянно, а время от времени обходит граф ссылок от корней (глобальных переменных и стеков живых горутин) и смотрит, до чего вообще можно дойти. До чего дошёл, то и живое, а всё остальное мусор. Циклы такой подход собирает сам собой: недостижимый цикл просто не встречается при обходе.

1. Мутатор — это твоя программа глазами сборщика

В литературе про сборку мусора прикладной код называют мутатором (англ. mutator — «тот, кто изменяет»). Название звучит обидно, но оно точное: с точки зрения сборщика программа только и делает, что перестраивает тот самый граф ссылок, который сборщик пытается обойти. Создаёт объекты, перевешивает указатели, обнуляет поля. В этом вся загвоздка: сборщик обходит граф, который у него под руками всё время переделывают. Слово пригодится, интервьюеры им пользуются.

2. Stop-the-world — это «весь сервис не отвечает»

STW (stop-the-world, «остановить мир») называют момент, когда рантайм приостанавливает все горутины программы и делает что-то в одиночку. Для сервиса это значит не «один поток подождал», а «ни один запрос сейчас не обрабатывается». Поэтому длину STW-пауз меряют и за неё борются: она попадает прямо в хвост латентности сервиса, в те самые p99.

3. Почему нельзя просто остановить всё и посчитать

Технически можно, и первые сборщики так и работали: остановил мир, обошёл весь граф, подмёл, отпустил. Проблема в цене. Время обхода пропорционально объёму живых данных: на кучу в 10 ГБ с десятками миллионов объектов уходят сотни миллисекунд, иногда секунды. Для сервиса с SLA «ответ за 50 мс» пауза в 300 мс означает отказ, и чем успешнее сервис (больше данных в памяти), тем пауза хуже. Поэтому Go пошёл на сделку: метить одновременно с работающей программой, оставив под STW только два коротких служебных момента, длина которых от размера кучи не зависит вообще. Платой за сделку и стала вся остальная сложность: цвета, инварианты, барьер записи.

4. Инвариант — правило, которое обязано выполняться всё время

Инвариант верен в любой момент работы алгоритма, в том числе на полпути. Пока сборщик работал под STW, инварианты были не нужны: граф не менялся, обход был очевидно корректен. Как только маркировка пошла параллельно с программой, потребовалось правило вида «вот такого состояния не бывает никогда». Без него не доказать, что живой объект не примут по дороге за мусор. Это правило и есть инвариант сборщика, а код, который его насильно удерживает, называется барьером.

Какой именно сборщик стоит в Go

Одной фразой: конкурентный (concurrent), неперемещающий (non-moving), трёхцветный mark & sweep с write barrier и без поколений. На собесе про каждое слово спрашивают отдельно. Mark & sweep буквально значит «пометить и подмести»: сначала обходим граф и метим всё живое, потом освобождаем непомеченное. Про Green Tea отдельный раздел ниже: с Go 1.26 это движок маркировки по умолчанию, и спрашивать про него начали сразу.

СвойствоЧто значитСледствие
Конкурентныйметит одновременно с работающим приложениемпаузы — доли миллисекунды, но GC отъедает ~25% CPU во время цикла
Параллельныймаркировкой заняты несколько воркеров сразуцикл масштабируется по ядрам
Неперемещающийобъекты в куче никогда не меняют адресуказатели стабильны, cgo и unsafe.Pointer работают; но куча фрагментируется, и её нельзя «уплотнить»
Mark & sweepобход графа живых, затем возврат мёртвых в спаныстоимость маркировки пропорциональна живым объектам, а не мусору
Без поколенийнет отдельной «молодой» кучироль young generation играет стек: escape analysis отправляет туда почти весь короткоживущий мусор
С барьером записикомпилятор вставляет хук на запись указателяэто цена корректности конкурентной маркировки
Почему Go не сделал поколенческий сборщик

Гипотеза поколений («большинство объектов умирает молодыми») в Go выполняется, но обслуживается не сборщиком, а компилятором: короткоживущие значения escape analysis оставляет на стеке, и они умирают бесплатно, вообще не попадая в кучу. Вдобавок поколенческий GC почти всегда перемещающий, а перемещение ломает совместимость с cgo и стабильность адресов. К 2018 году команда попробовала non-moving generational GC и отказалась: выигрыш не окупал цены барьера, который пришлось бы держать включённым всегда. Так глубоко на собесе обычно не копают.

Трёхцветная маркировка: зачем нужны именно три цвета

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

  • Белая комната — до неё ещё не дошли.
  • Серая — в неё зашли и пометили, но ещё не проверили, куда ведут двери из неё.
  • Чёрная — зашли и все двери из неё уже проверили; возвращаться сюда незачем.

Обход кончается, когда серых комнат не осталось: значит, всё, до чего есть путь от входов, уже почернело. В оставшиеся белые комнаты с улицы не попасть. Их можно сносить.

Почему три, а не два («посетили / не посетили»). Потому что обход надо уметь прерывать и продолжать. Серые объекты и составляют список незавершённых дел: сборщик работает порциями, между порциями крутится приложение, и без отдельного цвета «нашли, но не дочитали» после перерыва пришлось бы начинать сначала. А главное, правило корректности, ради которого всё затевалось, формулируется через пару «чёрный–белый».

Где аналогия перестаёт работать

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

Физически цвет не поле в объекте, отдельного байта под него нет. Есть биты маркировки в метаданных спана (один бит на объект) и очередь работы (work queue, распределённая по P в виде gcWork с локальными буферами). Цвет складывается из двух признаков:

  • Белый — бит маркировки не стоит, объекта нет в очереди. Кандидат в мусор.
  • Серый — бит стоит, но объект лежит в очереди: его нашли, а поля ещё не просмотрели.
  • Чёрный — бит стоит, из очереди объект уже вынули и просканировали его поля.

Алгоритм: покрасить корни (стеки всех горутин, глобальные переменные, регистры) в серый; пока очередь не пуста, доставать объект, красить его в чёрный, а все его белые «дети» красить в серый и класть в очередь. Когда очередь опустела, всё белое считается мусором.

Инвариант, который надо назвать дословно

Сильный трёхцветный инвариант: ни один чёрный объект не должен ссылаться на белый. У него есть ослабленная версия, слабый инвариант: чёрный может ссылаться на белый, но только если до этого белого остаётся путь от какого-нибудь серого. Эти два правила и охраняют барьеры записи, а за точной формулировкой обычно охотится интервьюер.

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

Объект теряется, когда одновременно случились две вещи: (1) ссылку на белый объект записали в уже чёрный и (2) удалили ту ссылку, по которой сборщик собирался до него дойти. Без барьера сборщик до белого объекта не доберётся, посчитает мусором и подметёт, а приложение потом прочитает освобождённую память.

белый — не видели серый — найден, поля не прочитаны чёрный — просканирован Нормальный ход маркировки 1. Корни красим в серый root A B очередь: [root] 2. Взяли root, покрасили детей root A B очередь: [A] 3. Очередь пуста — белых нет root A B очередь: [] — всё живое чёрное А теперь мутатор вмешивается — и без барьера объект теряется 1. root уже чёрный, A серый root A B B ждёт своей очереди через A 2. Приложение: root.p = B; A.p = nil root A ссылка снята B чёрный root теперь ссылается на белый B 3. Маркировка кончилась root A B B остался белым — sweep его освободит, а root на него всё ещё смотрит Барьер записи ломает этот сценарий: он перехватывает саму запись указателя и красит нужный объект в серый до того, как ссылка потеряется.
Почему нужен барьер. В верхнем ряду обход идёт без вмешательства. В нижнем гонка: чёрный получил ссылку на белого, а единственный «серый» путь к нему исчез. Эту пару событий барьер и обязан перехватить.

Барьер записи (write barrier): два классических и гибридный из Go 1.8

Барьер записи — это код, который компилятор вставляет перед каждой записью указателя в кучу на время фазы маркировки. Слово «барьер» здесь в смысле «контрольно-пропускной пункт», а не «преграда»: запись не запрещается, но не проходит незамеченной. Строчка obj.field = ptr в исходнике на время цикла GC превращается в «сообщи сборщику, что тут сейчас произойдёт, и только потом записывай». Так барьер и удерживает инвариант, пока программа перевешивает указатели у сборщика за спиной.

Барьер не бесплатен: он проверяет глобальный флаг writeBarrier.enabled и, если флаг взведён, пишет пару указателей в буфер P. Вне цикла GC флаг сброшен, и остаётся одна предсказуемая ветка, которую предсказатель ветвлений процессора берёт почти даром. Про это любят спрашивать отдельно: барьер стоит только на записи указателей, и только тех, что идут в кучу или в глобальные переменные. Запись int или запись в локальную переменную на стеке обходится без барьера, иначе каждое присваивание в программе стало бы дороже.

БарьерЧто делаетЧто охраняетЦена
Dijkstra (insertion)при slot = ptr красит новое значение ptr в серыйсильный инвариантстеки в конце надо пересканировать под STW
Yuasa (deletion)при slot = ptr красит старое значение *slot в серыйслабый инвариант («snapshot at the beginning»)консервативнее: спасает то, что уже умерло
Гибридный, Go 1.8+красит в серый и старое, и новое значениеслабый инвариант без пересканирования стековдве записи в буфер вместо одной
// Псевдокод гибридного барьера (runtime/mbarrier.go, hybrid Dijkstra-Yuasa)
func writePointer(slot *unsafe.Pointer, ptr unsafe.Pointer) {
    shade(*slot)                  // Yuasa: спасаем то, на что ссылались раньше
    if current_stack_is_grey() {  // так в предложении; в реализации условия нет — красятся оба
        shade(ptr)                // Dijkstra: спасаем то, что записываем
    }
    *slot = ptr
}
Что дал гибридный барьер в Go 1.8

До 1.8 стоял чистый барьер Dijkstra, и он охранял только записи в кучу, а записи в стек не барьерились. Из-за этого чёрный стек мог получить ссылку на белый объект незаметно для сборщика, и в конце цикла приходилось останавливать мир и пересканировать все стеки. Пауза росла вместе с числом горутин: 100 мс на большом сервисе были нормой. Гибридный барьер добавил Yuasa-часть, «снимок на начало цикла»: если ссылка была видна в момент старта, объект выживет в этом цикле независимо от того, куда её потом перевесили. Это позволило сканировать каждый стек ровно один раз и навсегда красить его в чёрный. Поэтому mark termination стал занимать десятки микросекунд и перестал зависеть от размера кучи и числа горутин.

Чем добить: «барьера чтения в Go нет»

Read barrier нужен перемещающим сборщикам (Java ZGC/Shenandoah): через него читатель узнаёт, что объект уехал. Go неперемещающий, поэтому чтение указателя бесплатно. По той же причине в Go нет уплотнения кучи, а unsafe.Pointer вообще может существовать как рабочий инструмент.

Фазы цикла и где именно останавливается мир

В цикле GC четыре фазы. Две из них короткие и идут под stop-the-world, две другие работают параллельно с приложением. Сам STW делает stopTheWorld: рантайм выставляет флаг преемптивности, и каждая горутина останавливается в ближайшей безопасной точке (с Go 1.14 преемпция асинхронная, по сигналу, поэтому цикл без вызовов функций больше не подвешивает сборщик).

  1. Sweep termination (STW). Домести хвост спанов с прошлого цикла, включить write barrier, перевести мир в режим маркировки. Десятки микросекунд.
  2. Mark (конкурентно). Сканируются корни (глобальные переменные, стеки горутин, каждый ровно один раз), затем обходится граф. Работают dedicated воркеры (25% от GOMAXPROCS) и fractional воркер, добирающий дробную часть. Есть ещё mark assist: горутина, аллоцирующая быстрее, чем сборщик успевает метить, сама делает пропорциональную часть маркировки прямо в mallocgc.
  3. Mark termination (STW). Убедиться, что очередь пуста, выключить write barrier, посчитать цель следующего цикла (pacer). Тоже десятки микросекунд.
  4. Sweep (конкурентно и лениво). Спаны метутся не разом, а фоновой горутиной и по требованию: когда mcache просит новый спан, тот подметается прямо перед выдачей. Поэтому «уборка» размазана по времени и почти не видна в профиле как отдельный пик.
Один цикл GC на временной оси STW 1 STW 2 Concurrent Mark 25% CPU: dedicated + fractional воркеры + mark assist Concurrent Sweep лениво: спан метётся перед выдачей в mcache до старта STW 1 · sweep termination — домести хвост прошлого цикла, включить write barrier, перевести мир в режим маркировки. STW 2 · mark termination — очередь пуста, выключить write barrier, посчитать цель следующего цикла. Обе паузы — десятки-сотни микросекунд и не зависят от размера кучи: с Go 1.8 стеки не пересканируются под STW. Горутины приложения работают, но платят mark assist за аллокации работают на полной скорости время Красным — stop-the-world. Всё остальное время сборщик работает параллельно с приложением, отъедая примерно четверть CPU.
Фазы GC. Пауз ровно две, обе короткие и почти постоянные по длительности. Настоящая цена сборщика не в паузах, а в тех 25% CPU на маркировке и в assist-работе, которую платит слишком быстро аллоцирующая горутина.
Классическая ошибка в ответе

«GC в Go останавливает мир на время sweep» — нет: sweep конкурентный и ленивый. «Пауза зависит от размера кучи» — с Go 1.8 нет. «Пауз три» — нет, две. И ещё: latency у сборщика отличная, но throughput он забирает. Если сервис тормозит во время GC, чаще виноваты не паузы, а mark assist и потерянные 25% CPU. Это разные проблемы, и лечат их по-разному.

Green Tea: как переделали маркировку в Go 1.26

Раз больше всего стоит маркировка, её и надо ускорять в первую очередь. Этим и занялись в Green Tea GC. В Go 1.25 он был экспериментом (GOEXPERIMENT=greenteagc), а с Go 1.26 включён по умолчанию. Отключить, если очень надо, можно через GOEXPERIMENT=nogreenteagc, но задать её придётся при сборке программы: переменной окружения на запуске тут не обойтись.

В чём была проблема. Классическая маркировка ходит по графу по одному объекту: достала указатель из очереди, прыгнула по нему, прочитала поля, положила детей в очередь, прыгнула дальше. С точки зрения алгоритма всё честно, а для железа хуже сценария не придумать: случайные прыжки по памяти. Указатели ведут куда угодно, предсказать следующий адрес процессор не может, поэтому почти каждый шаг обхода оборачивается промахом кэша и походом в оперативную память на ~80 нс. Разрыв между скоростью процессора и скоростью памяти за двадцать лет только рос, так что сборщик всё это время заметную часть маркировки (по замерам команды Go — от 35 %) просто ждал память, а не считал.

Что поменяли. Green Tea меняет единицу работы для мелких объектов (до 512 байт): в очереди лежат не отдельные объекты, а спаны, непрерывные блоки памяти одного размерного класса (те самые, из главы 3.1). Нашли живой объект — помечаем бит и ставим в очередь весь спан, в котором он лежит. Когда до спана доходит очередь, сборщик сканирует сразу все накопившиеся в нём помеченные объекты, за один заход.

  • Доступ становится последовательным. Объекты внутри спана лежат подряд, значит сканирование идёт линейно, как процессор и любит: работает предвыборка (prefetch), одна кэш-линия закрывает несколько объектов сразу.
  • Работа накапливается. Пока спан ждёт в очереди, в нём успевают пометиться другие объекты. За один визит сборщик обрабатывает сразу несколько, а не прыгает в ту же область памяти по разу на каждый.
  • Метаданные читаются один раз на спан, а не на каждый объект: биты маркировки лежат рядом и берутся пачкой.

Что это дало. −10…40 % накладных расходов GC на реальных нагрузках; разброс большой, потому что выигрыш зависит от того, насколько плотно живые объекты лежат в спанах: у сервиса с большим графом мелких структур эффект максимальный. Работа к тому же стала однородной и пачечной, и к ней получилось применить векторные инструкции (SIMD, когда одна инструкция обрабатывает сразу несколько значений). На свежих amd64 (Intel Ice Lake и новее, AMD Zen 4 и новее) они дают ещё примерно 10 % сверху.

Как это сказать на собесе

«В Green Tea переписали фазу маркировки, с Go 1.26 он включён по умолчанию. Идея в том, что старый обход графа шёл по одному объекту и упирался не в вычисления, а в промахи кэша: указатели ведут в случайные места памяти. Green Tea сделал единицей работы спан, а не объект, и сканирование стало последовательным и дружественным к кэшу, а заодно поддалось векторизации. В цифрах: минус 10–40 % накладных расходов GC и ещё около 10 % на свежих amd64. Алгоритм остался прежним: трёхцветный mark & sweep, инвариант и барьер записи не изменились, поменялся порядок обхода.» Последняя фраза показывает, что ты видишь, где проходит граница изменения.

Когда стартует цикл: pacer, GOGC, GOMEMLIMIT

Цикл запускается в трёх случаях:

  1. По достижении цели кучи. Основной путь. GOGC задаёт, на сколько процентов куче разрешено вырасти относительно живых данных прошлого цикла (с Go 1.18 к ним прибавляются стеки и глобальные переменные): target = live + (live + стеки + глобальные) × GOGC/100. При GOGC=100 (по умолчанию) цикл стартует, когда куча удвоилась. Следит за этим pacer: цели он буквально не дожидается, а стартует заранее, оценивая скорость аллокации, чтобы маркировка успела закончиться к моменту, когда куча дорастёт до цели.
  2. Принудительно, вызовом runtime.GC(): он блокирует вызывающую горутину до конца полного цикла (и, если предыдущий цикл ещё идёт, сначала дожидается его).
  3. По таймеру: sysmon форсирует цикл, если GC не запускался 2 минуты (forcegcperiod). Так простаивающий сервис всё-таки возвращает память ОС и не держит мусор бесконечно.

GOMEMLIMIT (Go 1.19) добавляет второй, независимый триггер: мягкий лимит на общий объём памяти рантайма. В него входят куча, стеки горутин, метаданные GC и внутренние структуры. Когда суммарный объём подбирается к лимиту, pacer запускает циклы чаще, вплоть до непрерывной работы, чтобы удержаться под ним. «Мягкий» означает, что рантайм не убьёт программу и не откажет в аллокации: если живых данных больше лимита, он просто будет молотить GC, а память всё равно вырастет.

GOGC=100 — по умолчанию live heap цель = live x 2 цикл стартует, когда куча удвоилась циклов мало, пик памяти высокий меньше CPU на GC GOGC=50 — жёстче live heap цель = live x 1.5 старт при меньшем приросте циклов вдвое больше, пик ниже CPU на GC заметно растёт GOMEMLIMIT в контейнере live heap растёт (кэш наполняется) GOMEMLIMIT куда тянет GOGC у лимита GC зовётся чаще и чаще потолок держится, OOM-kill не наступает платим CPU вместо падения пода
Пила и как её настроить. Зубчатый график памяти нормален и утечки не означает: на вершине стартует цикл, на спаде работает sweep. GOGC задаёт высоту зуба в процентах от живых данных, GOMEMLIMIT ставит абсолютный потолок и ценой лишних циклов не пускает пилу выше.
GOGC своими словами

«GOGC работает как ручка обмена памяти на CPU. Увеличил до 400 — циклов вчетверо меньше, CPU на GC падает, пиковая память растёт примерно в 2.5 раза. Уменьшил до 50 — наоборот. Цель считается от живых данных, а не от текущей кучи: если live set маленький (скажем, 20 МБ), GOGC=100 даст цикл каждые 20 МБ аллокаций, и сервис, который аллоцирует гигабайт в секунду, получит полсотни циклов в секунду. Поэтому таким сервисам помогает связка «поднять GOGC + поставить GOMEMLIMIT».»

Рабочий рецепт для контейнера
# лимит пода 1Gi. Запас — на то, чего GOMEMLIMIT не видит: бинарник, cgo, прочая не-Go память.
GOMEMLIMIT=800MiB
GOGC=off            # или большое значение, если боишься полной остановки GC
GOMAXPROCS=2        # до Go 1.25 (или при go ниже 1.25 в go.mod) выставляй вручную по CPU limit пода

Смысл: пока памяти вдоволь, GC не мешает; когда куча подбирается к 800 МБ, лимит включает сборщик и держит потолок. Этот паттерн разобран в официальном руководстве по GC (go.dev/doc/gc-guide). Про GOMAXPROCS: с Go 1.25 рантайм наконец учитывает CPU-лимит cgroup сам, если директива go в go.mod не ниже 1.25; до этого без automaxprocs Go видел все ядра ноды и создавал лишние P, что било и по планировщику, и по числу воркеров GC.

Что реально нагружает сборщик

Маркировка стоит пропорционально числу указателей в живых объектах, а не объёму памяти. Гигабайт []byte сборщик проходит мгновенно (спан помечен noscan, сканировать там нечего), а сто мегабайт map[string]*Item на миллион записей будут обходиться каждый цикл.

// грузит GC
type Cache struct {
    items map[string]*Item     // млн указателей
}
// рост слайса без cap: O(log n) перевыделений,
// каждое плодит объект и мусор
var out []Row
for _, r := range rows { out = append(out, r) }

// на каждой итерации новая строка в куче
for _, id := range ids {
    key := fmt.Sprintf("user:%d", id)
    _ = store[key]
}

// интерфейс боксит значение
var vals []any
for i := 0; i < n; i++ { vals = append(vals, i) }
// бережёт GC
type Cache struct {
    items map[string]Item      // значения: миллионы отдельных *Item исчезают (noscan мешают ключи-строки)
    idx   map[string]int32     // или индекс в плотный слайс
}
out := make([]Row, 0, len(rows))
for _, r := range rows { out = append(out, r) }

var b []byte
for _, id := range ids {
    b = append(b[:0], "user:"...)
    b = strconv.AppendInt(b, id, 10)
    _ = store[string(b)]       // конверсия в ключ мапы не аллоцирует
}

vals := make([]int, 0, n)      // конкретный тип, боксинга нет
  • Меньше указателей. []Item вместо []*Item, [16]byte вместо string для UUID, int32-индексы вместо ссылок. Структура без указателей внутри попадает в noscan-спан и для сборщика становится невидимой.
  • Преаллокация. make([]T, 0, n), make(map[K]V, n) убирают цепочку перевыделений и связанный с ней мусор.
  • Переиспользование. buf = buf[:0] внутри цикла, sync.Pool для крупных короткоживущих буферов, bytes.Buffer/strings.Builder вместо конкатенации.
  • Значения вместо указателей в горячих контейнерах дают и меньше объектов, и лучшую локальность кэша.
  • Арены и батчинг: один большой слайс и индексы в него вместо миллиона мелких объектов. Это тот же приём, что в ECS и в колоночных движках.
Антипаттерны, которые встречаются чаще всего
  • fmt.Sprintf в горячем цикле: и боксинг аргументов через any, и рефлексия, и новая строка на каждый вызов.
  • append в слайс без cap при известной длине.
  • Дерево из мелких объектов с указателями (связный список, *node) там, где хватило бы плоского слайса.
  • Логирование на «горячем» уровне с интерфейсными аргументами без проверки Enabled().
  • defer в цикле: и отложенное освобождение, и рост списка defer-записей.
  • Мапа со строковыми ключами, которые собираются на каждый запрос заново.

Мониторинг GC

Три источника данных, от самого дешёвого к самому подробному:

# 1. Не трогая код: по строке на каждый цикл GC в stderr
# Настоящий вывод go1.27 (GOMAXPROCS=8, программа льёт мусор в цикле), четыре подряд:
$ GODEBUG=gctrace=1 ./service
gc 40 @0.164s 4%: 0.010+2.7+0.010 ms clock, 0.086+0.33/4.8/8.3+0.080 ms cpu, 30->31->18 MB, 34 MB goal, 0 MB stacks, 0 MB globals, 8 P
gc 41 @0.175s 4%: 0.009+1.5+0.010 ms clock, 0.077+0.13/2.6/6.4+0.086 ms cpu, 32->33->18 MB, 37 MB goal, 0 MB stacks, 0 MB globals, 8 P
gc 42 @0.184s 4%: 0.011+1.8+0.019 ms clock, 0.095+0.13/3.2/7.3+0.15 ms cpu, 32->33->19 MB, 37 MB goal, 0 MB stacks, 0 MB globals, 8 P
gc 43 @0.195s 4%: 0.013+1.9+0.027 ms clock, 0.10+0.012/3.3/7.8+0.22 ms cpu, 33->35->20 MB, 39 MB goal, 0 MB stacks, 0 MB globals, 8 P

# читается так:
#   gc 42          — номер цикла
#   @0.184s        — время от старта программы
#   4%             — доля CPU, съеденная сборщиком с момента старта
#   0.011+1.8+0.019 ms clock  — STW1 + конкурентная маркировка + STW2
#   32->33->19 MB  — куча в начале цикла -> в конце маркировки -> живые данные
#   37 MB goal     — цель, посчитанная pacer-ом
#   8 P            — GOMAXPROCS
// 2. runtime/metrics: стабильный и работает без stop-the-world
import "runtime/metrics"

samples := []metrics.Sample{
    {Name: "/gc/heap/live:bytes"},            // живые данные после последнего цикла
    {Name: "/gc/heap/goal:bytes"},            // цель pacer-а
    {Name: "/gc/cycles/total:gc-cycles"},     // сколько циклов прошло
    {Name: "/sched/pauses/total/gc:seconds"}, // гистограмма пауз GC
    {Name: "/memory/classes/total:bytes"},    // вся память рантайма; за вычетом heap/released её и ограничивает GOMEMLIMIT
    {Name: "/sched/goroutines:goroutines"},   // ловит утечку горутин
}
metrics.Read(samples)

Третий источник, runtime.ReadMemStats, старше остальных. Данные в нём те же, только в плоском виде, но он останавливает мир на время снятия. В горячем цикле экспорта метрик его лучше не звать чаще раза в 10–15 секунд, а новым сервисам стоит сразу брать runtime/metrics.

На что смотреть в дашборде

Пила — это норма. Проблему выдают не зубцы, а поведение нижней огибающей: если минимум после каждого цикла ползёт вверх, растёт live set, и это утечка. Плюс три сигнала: доля CPU на GC выше 10–15% (сервис аллоцирует слишком много), частота циклов в секунду (если их слишком много, мал live set при большом трафике аллокаций) и /sched/pauses/total/gc в хвосте (при p99 паузы выше миллисекунды ищи длинные безопасные точки или слишком большое число горутин).

Утечка памяти в языке со сборщиком мусора

Сборщик освобождает недостижимые объекты. Утечкой в Go всегда оказывается достижимый мусор: данные, которые логически не нужны, но на них остаётся ссылка от корня. GC тут бессилен по определению: намерений он читать не умеет.

ПричинаКак выглядитЛечение
Глобальная мапа/кэш без вытесненияvar cache = map[string]*Obj{} и только записьTTL, LRU, ограничение размера; и помнить: delete уменьшает len, но не память мапы
Утечка горутингорутина навсегда встала на канале или select без ctx.Done()context, таймауты, закрытие каналов; мониторить /sched/goroutines, а с Go 1.27 снимать профиль goroutineleak (глава 3.3)
Подслайс держит большой массивsmall := big[:10] — жив весь bigslices.Clone или явное копирование
Подстрокасрез большой строки держит её целикомstrings.Clone (Go 1.18+)
Забытый time.Tickerдо Go 1.23 тикер без Stop() не собирался никогда; теперь его соберут, когда пропадут ссылкиdefer t.Stop()
Не закрытые resp.Bodyсоединение, горутины транспорта и буферы висят, GC их не освободитdefer resp.Body.Close() + вычитать тело
«Хвост» слайса после удаления элементаs = s[:len(s)-1] оставляет указатель в массивеобнулить последний элемент перед обрезкой
Замыкание, захватившее лишнееколбэк в долгоживущей структуре тянет весь запросзахватывать только нужные поля
// Классика: 10-байтовый подслайс держит 10 МБ
func head(path string) []byte {
    data, _ := os.ReadFile(path)   // 10 МБ
    return data[:10]               // весь массив остаётся живым
}
func headFixed(path string) []byte {
    data, _ := os.ReadFile(path)
    return append([]byte(nil), data[:10]...)  // с копией 10 МБ освободятся
}

// Классика: указатели в хвосте слайса
func pop(s []*Item) []*Item {
    s[len(s)-1] = nil              // обязательно, иначе объект достижим из массива
    return s[:len(s)-1]
}

Финалайзеры и runtime.AddCleanup

runtime.SetFinalizer(obj, fn) регистрирует функцию, которую рантайм вызовет когда-нибудь после того, как объект станет недостижимым. Проблем столько, что в проде это фактически запрещённый приём:

  • Нет гарантии вызова вообще. При выходе из программы финалайзеры не запускаются.
  • Нет гарантии времени. Между «объект умер» и «финалайзер вызван» может пройти сколько угодно циклов.
  • Объект живёт минимум на цикл дольше. Финалайзер воскрешает объект: сначала цикл обнаруживает недостижимость, ставит его в очередь (объект снова достижим из очереди), и только следующий цикл его действительно освободит.
  • Циклы не собираются. Если два объекта с финалайзерами ссылаются друг на друга, не освободится ни один.
  • Финалайзер держит объект. Замыкание, захватившее сам obj, делает его бессмертным, и это типовой баг.
  • Выполняется в отдельной горутине, паника в нём валит процесс.
// Go 1.24: замена SetFinalizer, без воскрешения и без утечек на циклах
type File struct {
    fd      int
    cleanup runtime.Cleanup
}

func Open(name string) (*File, error) {
    fd, err := syscall.Open(name, 0, 0)
    if err != nil { return nil, err }
    f := &File{fd: fd}
    // cleanup получает копию нужных данных, а не сам объект,
    // поэтому не держит f и не откладывает его освобождение
    f.cleanup = runtime.AddCleanup(f, func(fd int) { syscall.Close(fd) }, fd)
    return f, nil
}
// но правильный API всё равно даёт явный Close(), и он снимает страховку:
// иначе cleanup позже закроет номер дескриптора, который уже достался другому файлу
func (f *File) Close() error { f.cleanup.Stop(); return syscall.Close(f.fd) }
Две вещи, которые добавили в Go 1.25
  • runtime.AddCleanup выполняется параллельно. Раньше cleanup-ы разбирала та же одна горутина, что и финалайзеры, строго по очереди, и один медленный обработчик подвешивал все остальные. Теперь cleanup-ы раскладываются по нескольким горутинам, и очередь перестала быть узким местом. Финалайзеры по-прежнему идут в одной горутине. Для программы, которая вешает cleanup на каждый объект (буферы, дескрипторы, cgo-память), разница принципиальная.
  • GODEBUG=checkfinalizers=1 включает режим диагностики. Рантайм на каждом цикле печатает размер очереди и, главное, ловит классические ошибки: замыкание финалайзера, захватившее сам объект (тот самый «бессмертный объект»), и cleanup, привязанный к объекту, до которого можно дойти из самого cleanup-а. Найдя такое, рантайм показывает, где финалайзер создан, и валит программу фатальной ошибкой. Выглядит так:
    $ GODEBUG=checkfinalizers=1 ./service
    checkfinalizers: queue: 0 finalizers + 0 cleanups
    WARNING: LIKELY CLEANUP/FINALIZER ISSUES
    
    Value of type main.File at 0x5468152c00e0
      is reachable from finalizer
    
    Has finalizer at 0x10046de00
      main.Open.func1()
          service/main.go:18 +0x0
    created at: 
      main.Open()
          service/main.go:19 +0xac
    
    fatal error: detected possible issues with cleanups and/or finalizers
    Держать включённым в проде не надо (лишние проверки под stop-the-world на каждом цикле и падение процесса на первой находке), но интеграционные тесты с этим флагом прогнать стоит: так дёшево находится утечка, которая иначе живёт месяцами.
Правило

Финалайзер и AddCleanup нужны как страховка от чужой забывчивости, а не как механизм освобождения ресурсов. Так их и применяют в стандартной библиотеке: os.File закрывает дескриптор, если пользователь забыл Close(). Детерминированное освобождение в Go делается через Close() + defer, и никак иначе. На собесе так и говори: «единственный корректный сценарий — подстраховка и диагностика утечек, всё остальное — явный Close».

Вопросы

11
Суть: конкурентный, параллельный, неперемещающий, без поколений трёхцветный mark & sweep с барьером записи. Метит одновременно с приложением, забирая около 25% CPU, и останавливает мир всего дважды на десятки микросекунд. С Go 1.26 маркировка идёт движком Green Tea — сканирование спанами вместо прыжков по отдельным объектам.

Цвета

  • Белый — бит маркировки не стоит, объекта нет в очереди: кандидат в мусор.
  • Серый — бит стоит, объект в очереди работы: нашли, но поля ещё не прочитали.
  • Чёрный — бит стоит, поля просканированы, из очереди вынут.

Сам цвет не поле объекта. Физически есть биты маркировки в метаданных спана (gcmarkBits) и распределённая очередь работы: у каждого P свой буфер gcWork, при переполнении он отдаётся в глобальную очередь, откуда его крадут другие воркеры. За счёт этого маркировка и масштабируется по ядрам.

Ход цикла

  1. Корни (глобальные переменные, стеки всех горутин, регистры) красятся в серый.
  2. Пока очередь не пуста: достать объект, просканировать его поля по карте указателей типа, все белые дети — в серый и в очередь, сам объект — чёрный.
  3. Очередь пуста → всё белое недостижимо → sweep возвращает эти блоки в спаны.

Что назвать, чтобы ответ был сильным

  • Неперемещающий: адреса стабильны, барьера чтения нет, cgo и unsafe.Pointer работают; платить приходится фрагментацией и невозможностью уплотнения.
  • Без поколений: роль young generation исполняет стек, куда escape analysis отправляет почти весь короткоживущий мусор, и там он умирает бесплатно.
  • Стоимость пропорциональна живым данным, а не мусору: гигабайт []byte (noscan-спан) сборщик не сканирует вообще, а map[string]*Item на 10 млн записей обходится каждый цикл.
  • Цель — latency, а не throughput. Go сознательно отдал пропускную способность ради коротких пауз: 25% CPU во время цикла и есть эта плата, заложенная в дизайн.
  • Green Tea — маркировка по умолчанию с Go 1.26. Экспериментом был в 1.25 (GOEXPERIMENT=greenteagc), теперь включён сам; выключается GOEXPERIMENT=nogreenteagc. Суть: для мелких объектов (до 512 байт) единицей работы стал спан, а не отдельный объект. Старый обход прыгал по указателям в случайные места памяти и упирался не в вычисления, а в промахи кэша; Green Tea ставит в очередь спан целиком и сканирует все накопившиеся в нём помеченные объекты разом. Доступ становится последовательным, работает предвыборка, метаданные читаются пачкой. Итог: −10…40 % накладных расходов GC, плюс ещё около 10 % на свежих amd64 (Intel Ice Lake и новее, AMD Zen 4 и новее) за счёт векторных инструкций. Сам алгоритм не изменился: те же три цвета, тот же инвариант, тот же барьер записи, поменялся только порядок обхода.
Уточняющие, на которых сыпятся

«Sweep под STW?» — нет, конкурентный и ленивый: спан метёт фоновая горутина или аллокация в момент выдачи в mcache. «GC перемещает объекты?» — нет. «Сколько поколений?» — ни одного. «Куда девается память после sweep?» — сначала обратно в спаны рантайма, ОС она возвращается отдельно и постепенно (MADV_FREE/MADV_DONTNEED), поэтому RSS падает не сразу. На этом ловят при разборе «памяти в дашборде».

Суть: код, который компилятор вставляет перед каждой записью указателя на время маркировки. Он не даёт приложению «спрятать» живой объект от сборщика, перевесив ссылку у него за спиной.

Какую гонку он ловит

Беда случается, когда совпадают два события: ссылку на белый объект записали в уже чёрный, и одновременно удалили ту ссылку, по которой сборщик до белого собирался дойти. Чёрный объект повторно не сканируется, значит, белый останется белым, будет подметён, и приложение получит указатель в освобождённую память. Барьер перехватывает саму запись и красит нужный объект в серый.

Три варианта

Что краситИнвариантЦена
Dijkstra, insertionновое значение ptrсильный: чёрный никогда не смотрит на белыйнужен STW-пересканинг стеков в конце
Yuasa, deletionстарое значение *slotслабый: снимок графа на начало циклаконсервативен — сохраняет уже умершее
Гибрид Go 1.8+и старое, и новоеслабый, без пересканирования стековдве записи в буфер вместо одной
// то, что компилятор подставляет вместо obj.field = ptr во время маркировки
func gcWriteBarrier(slot *unsafe.Pointer, ptr unsafe.Pointer) {
    if writeBarrier.enabled {     // вне цикла GC это одна предсказуемая ветка
        shade(*slot)              // Yuasa: спасаем старую цель
        if stackIsGrey(getg()) {  // упрощение: рантайм кладёт в буфер оба указателя всегда
            shade(ptr)            // Dijkstra: спасаем новую цель
        }
    }
    *slot = ptr
}

Ключевое следствие гибрида (то, ради чего его сделали)

Записи в стек барьером не покрываются, иначе каждое присваивание локальной переменной стало бы дорогим. При чистом Dijkstra это означало, что стеки нельзя считать окончательно чёрными, и в конце цикла их пересканировали под stop-the-world: пауза росла с числом горутин и доходила до сотен миллисекунд. Yuasa-часть даёт «снимок на начало цикла»: всё, что было достижимо в момент старта, переживает этот цикл. Поэтому стек сканируется один раз и красится в чёрный навсегда, а mark termination стал занимать десятки микросекунд и перестал зависеть от размера кучи.

Про цену и про барьер чтения

Барьер стоит единицы наносекунд на запись указателя, и платишь их только во время маркировки; вне цикла остаётся проверка глобального флага, которую предсказатель ветвлений берёт почти бесплатно. И полезная добивка: read barrier в Go нет вообще. Он нужен перемещающим сборщикам (ZGC, Shenandoah), чтобы читатель узнал о переезде объекта. Go неперемещающий, поэтому чтение указателя ничего не стоит.

Суть: пауз ровно две — sweep termination в начале цикла и mark termination в конце. Обе длятся десятки-сотни микросекунд и почти не зависят от размера кучи. Маркировка и уборка идут параллельно с приложением.
  1. Sweep termination (STW). Домести спаны, оставшиеся от прошлого цикла (sweep ленивый, хвост мог не разобраться), включить write barrier, перевести все P в режим маркировки.
  2. Mark (конкурентно). Сканируются корни: глобальные переменные и стек каждой горутины (ровно один раз за цикл; горутина при этом коротко приостанавливается, но общего STW нет). Затем обход графа. Работают dedicated воркеры (25% от GOMAXPROCS), fractional-воркер на дробную часть и mark assist у аллоцирующих горутин.
  3. Mark termination (STW). Проверить, что очередь работы пуста, выключить write barrier, посчитать цель следующего цикла, сбросить статистику.
  4. Sweep (конкурентно и лениво). Спаны подметает фоновая горутина, а недометённый спан подметается в момент, когда mcache просит новый. Значит, стоимость размазана по времени и аллокациям и отдельного пика в профиле не даёт.

Как физически реализуется STW

stopTheWorld выставляет у каждого P флаг преемпции и ждёт, пока все горутины дойдут до безопасной точки. До Go 1.14 такой точкой был только вызов функции, поэтому «горячий цикл без вызовов» мог задержать весь STW на неопределённое время. Отсюда знаменитый баг с зависанием на for {}. С 1.14 работает асинхронная преемпция: рантайм шлёт потоку сигнал SIGURG и останавливает горутину практически где угодно.

Правильная мысль про «GC тормозит сервис»

У Go сборщик оптимизирован по latency, а не по throughput. Если сервис проседает во время GC, паузы почти наверняка ни при чём: смотри на mark assist (горутина аллоцирует быстрее, чем сборщик метит, и вынуждена метить сама; снаружи это выглядит как рост латентности конкретных запросов) и на потерянные 25% CPU. Лечат их по-разному: assist убирают, снижая аллокации, а потерю CPU снимают повышением GOGC. В ответе эти две вещи стоит развести.

Суть: три триггера — достижение цели кучи (target = live + (live + стеки + глобальные) × GOGC/100, отслеживает pacer), явный runtime.GC(), и форс от sysmon, если циклов не было 2 минуты. С Go 1.19 добавился четвёртый — приближение к GOMEMLIMIT.

1. Цель кучи и pacer

GOGC задаёт процент прироста относительно живых данных прошлого цикла (с Go 1.18 к ним прибавляются стеки и глобальные переменные). При GOGC=100 и live set 100 МБ следующий цикл целится примерно в 200 МБ. Но pacer не ждёт достижения цели буквально: он оценивает скорость аллокации и скорость маркировки и стартует заранее, чтобы маркировка успела закончиться ровно к тому моменту, как куча дорастёт до цели. Если pacer ошибся и приложение аллоцирует быстрее, включается mark assist, который тормозит аллокаторов и не даёт куче перелететь цель.

// живых данных 100 МБ, стеки и глобальные ничтожны:
GOGC=100  -> цель 200 МБ   // по умолчанию
GOGC=50   -> цель 150 МБ   // циклов больше, память ниже, CPU выше
GOGC=400  -> цель 500 МБ   // циклов меньше, память выше, CPU ниже
GOGC=off  -> цели нет      // GC не запускается по росту кучи

debug.SetGCPercent(200)    // то же самое из кода, возвращает старое значение

2. Принудительно

runtime.GC() блокирует вызывающую горутину до завершения полного цикла; если цикл уже идёт, сначала дожидается его, потом запускает свой. Звать его законно перед снятием heap-профиля (чтобы inuse отражал реально живое), в тестах на утечки, перед долгой паузой в работе демона. В обычном коде такой вызов почти всегда ошибка. Есть ещё debug.FreeOSMemory(): он делает runtime.GC() и вдобавок агрессивно возвращает страницы ОС.

3. По таймеру

Системный монитор sysmon форсирует цикл, если с последнего прошло больше 2 минут (константа forcegcperiod в runtime/proc.go). Простаивающий сервис не аллоцирует, цель кучи не достигается никогда, и без таймера он бесконечно держал бы мусор и не отдавал память ОС.

Ловушка про маленький live set

Цель считается от живых данных, поэтому у сервиса с крошечным live set (скажем, 4 МБ) и высоким темпом аллокаций GOGC=100 означает цикл каждые 4 МБ, сотни циклов в секунду и заметную долю CPU на GC при почти пустой памяти. Симптом в gctrace: очень частые циклы и высокий процент CPU. Лечится это так: поднять GOGC и поставить GOMEMLIMIT как реальный потолок, либо debug.SetMemoryLimit + GOGC=off. Пример «взрослый», его редко приводят.

Суть: GOMEMLIMIT (Go 1.19) — мягкий лимит на всю память рантайма. По мере приближения к нему pacer запускает GC всё чаще, вплоть до непрерывной работы. Решает главную боль контейнеров: GOGC задаёт относительный порог и ничего не знает про абсолютный лимит пода.

Проблема, которую он закрыл

Под с лимитом 1 ГБ. Живых данных обычно 300 МБ, GOGC=100 → цель 600 МБ, всё хорошо. Приходит всплеск трафика, live set подскакивает до 600 МБ → цель 1.2 ГБ → OOM-kill до того, как сборщик успел что-то предпринять. До 1.19 выкручивались через ballast: выделить make([]byte, 1<<30), никогда его не трогать (страницы не коммитятся, RSS не растёт) и тем самым искусственно задрать базу для расчёта цели. Костыль работал, но был хрупким и непонятным. GOMEMLIMIT сделал это штатной ручкой.

Что именно ограничивается

В лимит входит больше, чем куча: куча + стеки горутин + метаданные GC + внутренние структуры рантайма, то есть всё, что в runtime/metrics называется /memory/classes/total:bytes, минус то, что уже возвращено ОС. Вне лимита остаётся память, о которой рантайм не знает: cgo, mmap руками, память драйверов. Поэтому лимит ставят с запасом.

# лимит пода 1Gi -> оставляем 20% запаса
GOMEMLIMIT=800MiB
GOGC=off        # GC срабатывает только у потолка: максимум throughput, минимум циклов
GOMAXPROCS=2    # до Go 1.25 (или при go ниже 1.25 в go.mod) — automaxprocs или вручную
import "runtime/debug"

func main() {
    // то же из кода, в байтах; лимит cgroup лежит в /sys/fs/cgroup/memory.max
    debug.SetMemoryLimit(800 << 20)
    debug.SetGCPercent(-1)  // GOGC=off
}

Сценарии, где он реально нужен

  • Контейнер с жёстким лимитом, основной случай: OOM-kill превращается в замедление.
  • Сервис с большим кэшем в памяти: кэш растёт, но не должен утащить под за границу.
  • Batch/CLI-задачи: GOGC=off + лимит = максимум скорости при известном потолке.
  • Сервис с маленьким live set и высоким темпом аллокаций: GOGC побольше, лимит как страховка.
Смертельная ловушка: GC death spiral

Лимит мягкий: если живых данных стало больше лимита, рантайм не упадёт и не откажет в аллокации, а начнёт запускать GC почти непрерывно. Весь CPU сборщик не съест: с Go 1.19 лимитер ограничивает его примерно половиной CPU и дальше пускает память за лимит. Но сервис заметно замедляется, оставаясь формально живым, и это хуже быстрого OOM-kill, потому что health-check может не заметить. А если память дорастёт до лимита пода, OOM-kill всё равно придёт. Защищаются так: runtime/debug.SetMemoryLimit вместе с разумным GOGC (не off), мониторинг доли CPU на GC и лимит с запасом 15–25% от реального лимита пода. Кто назовёт death spiral на собесе, тот явно это включал в проде, а не читал в релиз-ноутах.

Суть: отключить — да, GOGC=off или debug.SetGCPercent(-1): сборщик перестаёт запускаться по росту кучи. Но это не «выключить совсем» — runtime.GC() и GOMEMLIMIT продолжают работать; молчит только форс по таймеру. «Поставить на паузу» отдельной кнопкой нельзя.
debug.SetGCPercent(-1)        // выключить, как GOGC=off
old := debug.SetGCPercent(400) // изменить, вернуть предыдущее
runtime.GC()                   // синхронно прогнать полный цикл
debug.FreeOSMemory()           // цикл + агрессивно вернуть страницы ОС
debug.SetMemoryLimit(n)        // потолок; работает даже при GOGC=off

Когда выключение оправдано

  • Короткоживущие CLI и batch-задачи: процесс живёт минуту, память заведомо влезает. Зачем платить за сборку того, что ОС всё равно освободит при выходе?
  • Связка GOGC=off + GOMEMLIMIT, паттерн из официального руководства по GC (go.dev/doc/gc-guide): GC не мешает, пока памяти вдоволь, и включается ровно у потолка.
  • Латентно-критичные окна (торговые системы): выключить на время сессии, прогнать runtime.GC() в паузе.
  • Бенчмарки и профилирование: так из измерений уходит шум сборщика.

Когда оправдана настройка GOGC (без выключения)

СимптомКуда крутитьЧто получишь
Доля CPU на GC 15%+ в gctrace, памяти вдовольGOGC вверх (200–500)меньше циклов, ниже CPU, выше пик памяти
Много циклов в секунду при крошечном live setGOGC вверх + GOMEMLIMITубирает «холостую молотилку»
Память впритык, CPU естьGOGC вниз (50)ниже пик, дороже по CPU
Периодические OOM-kill при всплескахGOMEMLIMIT, не GOGCабсолютный потолок вместо относительного
Порядок действий, который стоит проговорить

Крутить GOGCпоследнее средство, а не первое. Сначала: снять heap-профиль, убрать лишние аллокации, преаллоцировать, убрать лишние указатели. Настройка GOGC мусора не уменьшает, а лишь даёт ему дольше копиться. Если после ручки сервис «стал быстрее», а аллокации остались, значит, ты купил latency за память, и при следующем всплеске трафика получишь OOM. Поэтому любую такую настройку обязательно сопровождай GOMEMLIMIT.

Суть: сборщик платит за количество объектов и указателей в них, а не за байты. Три рычага: не аллоцировать вовсе (стек, преаллокация), аллоцировать реже (переиспользование, пулы), аллоцировать «плоско» (значения вместо указателей).

1. Меньше объектов

  • Преаллокация: make([]T, 0, n) убирает цепочку перевыделений; каждое из них порождает новый объект и отправляет старый в мусор. make(map[K]V, n) делает то же для мап и избавляет от серии рехэшей.
  • Переиспользование буфера: buf = buf[:0] сохраняет ёмкость и обнуляет длину, так что после прогрева аллокаций нет.
  • Батчинг: один слайс на N элементов вместо N отдельных объектов.

2. Меньше указателей

Спан, в котором лежат объекты без указателей, помечен как noscan, и сборщик в него не заглядывает. Отсюда:

// GC обходит 10 млн объектов каждый цикл
items := make([]*Item, 10_000_000)

// GC не сканирует ничего, если в Item нет указателей
items := make([]Item, 10_000_000)

// UUID как строка = указатель + длина, объект в куче
type Row struct {
    ID string
}
// UUID как массив = 16 байт внутри структуры, noscan
type Row struct {
    ID [16]byte
}

// ссылки заменяем индексами в плотный слайс
type Node struct {
    Left, Right int32
}
nodes := make([]Node, n)

3. sync.Pool — и его правила

var bufPool = sync.Pool{
    New: func() any { return new(bytes.Buffer) },
}

func handle(w http.ResponseWriter, r *http.Request) {
    b := bufPool.Get().(*bytes.Buffer)
    b.Reset()                 // обязательно: в пуле лежит грязный объект
    defer bufPool.Put(b)
    // ... пишем в b, отдаём ответ ...
}
  • Пул не ограничивает число объектов и не служит кэшем: содержимое чистится каждый цикл GC (точнее, victim cache отдаёт объектам ещё один цикл жизни).
  • Кладя объект обратно, обнуляй его, иначе данные утекут между запросами (реальный класс уязвимостей), а память останется занятой.
  • Не клади в пул слайсы разного размера: один жирный буфер будет держаться вечно. Обычно в пул просто не возвращают то, что выросло больше порога.
  • Пул выигрывает только на крупных объектах: Get/Put сами по себе стоят около десятка наносекунд, для мелочи это дороже аллокации.
  • Значение из пула нельзя использовать после Put, но на этом регулярно попадаются.

Что грузит GC

ПаттернПочему дорогоЗамена
fmt.Sprintf в горячем циклебоксинг через any, рефлексия, новая строкаstrconv.Append*, strings.Builder
append без capO(log n) перевыделений и столько же мусорных массивовmake([]T, 0, n)
[]*T на миллионы элементовмиллион объектов для обхода каждый цикл[]T или индексы
Связные списки, деревья из *nodeмаксимум указателей на единицу данныхплоские массивы, арены
Строка ↔ []byte туда-сюдакопия на каждой конверсииработать в одном представлении; string(b) как ключ мапы оптимизируется компилятором
Логирование с any-аргументамибоксинг даже если уровень выключенпроверка Enabled(), типизированные атрибуты slog
Мапа с ключами, собираемыми на летустрока в куче на каждый lookupпереиспользуемый []byte + string(b) в индексе
defer внутри цикларост списка defer и отложенное освобождениевынести тело в функцию
Как это звучит на собесе

«Я смотрю не на B/op, а на allocs/op: сборщик платит за объекты, а не за байты. И не на скорость аллокации, а на то, сколько указателей будет в живых данных: маркировка стоит пропорционально им. Гигабайтный []byte для GC бесплатен, а map[string]*Item на 10 миллионов записей обходится дороже всего, что можно положить в кучу.»

Суть: три источника — GODEBUG=gctrace=1 для быстрого взгляда, runtime/metrics для постоянного экспорта, runtime.ReadMemStats как легаси (он делает STW). Пила на графике — норма; тревожит не она, а ползущая вверх нижняя огибающая.

gctrace — когда надо посмотреть здесь и сейчас

$ GODEBUG=gctrace=1 ./service 2>&1 | grep '^gc '
gc 43 @0.912s 1%: 0.028+1.6+0.013 ms clock, ..., 107->108->54 MB, 110 MB goal, 0 MB stacks, 0 MB globals, 8 P
#     |       |   |     |   |                    |    |    |      |
#     |       |   |     |   STW2                 |    |    live   цель pacer-а
#     |       |   |     конкурентная маркировка  |    куча в конце
#     |       |   STW1                           куча в начале цикла
#     |       доля CPU на GC с момента старта
#     время от старта

# полезные соседи:
$ GODEBUG=gctrace=1,scavtrace=1 ./service   # ещё и возврат памяти ОС
$ GODEBUG=inittrace=1 ./service             # долгие init(), частая причина медленного старта

Что экспортировать в Prometheus

samples := []metrics.Sample{
    {Name: "/gc/heap/live:bytes"},            // живые данные, главный признак утечки
    {Name: "/gc/heap/goal:bytes"},            // цель pacer-а
    {Name: "/gc/heap/allocs:bytes"},          // сколько всего нааллоцировано (счётчик)
    {Name: "/gc/cycles/total:gc-cycles"},     // частота циклов
    {Name: "/sched/pauses/total/gc:seconds"}, // гистограмма пауз, смотреть p99
    {Name: "/cpu/classes/gc/total:cpu-seconds"}, // доля CPU на GC
    {Name: "/memory/classes/total:bytes"},    // то, что ограничивает GOMEMLIMIT
    {Name: "/sched/goroutines:goroutines"},   // ловит утечку горутин
}
metrics.Read(samples)

Если сервис уже отдаёт promhttp, коллекторы collectors.NewGoCollector с WithGoCollectorRuntimeMetrics подтягивают это без ручного кода.

Как читать пилу

  • Зубцы сами по себе нормальны: вершина = момент старта цикла, спад = освобождение. Отсутствие зубцов при активном трафике подозрительно сильнее, чем их наличие.
  • Нижняя огибающая ползёт вверх, значит, растёт live set. Это либо утечка, либо прогрев кэша (у кэша рост выходит на плато, а у утечки нет).
  • Зубцы стали чаще и мельче: то ли куча подошла к GOMEMLIMIT, то ли упал live set.
  • RSS не падает вслед за heap, и это не утечка: рантайм отдаёт страницы ОС лениво, фоновым scavenger-ом, поэтому RSS отстаёт от кучи. На Linux Go по умолчанию отдаёт их через MADV_DONTNEED, и тогда они из RSS уходят; задержка тут не от разметки, а от неспешности возврата. MADV_FREE («можешь забрать, если понадобится», RSS остаётся высоким до нехватки памяти) включается только через GODEBUG=madvdontneed=0. Смотреть надо на /gc/heap/live, а не на RSS контейнера.
Почему ReadMemStats лучше не звать часто

runtime.ReadMemStats останавливает мир на время копирования структуры. На сервисе с тысячами горутин и экспортом раз в секунду это заметная доля пауз, причём их создаёт сам мониторинг. runtime/metrics (Go 1.16+) читает те же данные без STW и с версионированными именами метрик. Для новых сервисов выбор однозначен.

Суть: сборщик освобождает недостижимое. Утечка в Go — это достижимый мусор: данные, которые логически не нужны, но на них осталась ссылка от корня. GC про «логически не нужны» ничего не знает.

Корни, из которых тянутся ссылки

Глобальные переменные пакетов, стеки всех живых горутин, значения в sync.Pool, объекты, зарегистрированные в рантайме (таймеры, тикеры, финалайзеры), а также всё, что достижимо из этого транзитивно. Утечка = что-то из этого списка держит ссылку слишком долго.

Типовые причины

  1. Глобальный кэш/мапа без вытеснения. Только пишем, никогда не удаляем. Есть и отдельный нюанс: delete из мапы память не возвращает, и мапа, разросшаяся до миллиона записей, после очистки держит таблицу прежнего размера. Вернуть память можно только одним способом: создать новую мапу.
  2. Утечка горутин в проде встречается чаще всех. Горутина, навсегда вставшая на отправке в небуферизованный канал или на select без ctx.Done(), держит свой стек (минимум 2 КБ, часто больше) и всё, до чего дотягивается из него. Тысяча зависших горутин с телом запроса внутри съедает сотни мегабайт.
  3. Подслайс держит весь массив. small := big[:10]cap остался от big, значит жив весь исходный массив.
  4. Подстрока держит исходную строку по той же механике.
  5. «Хвост» слайса после удаления. s = s[:len(s)-1] оставляет указатель в подложке массива: элемент недоступен, но достижим.
  6. Забытые time.Ticker / time.AfterFunc. Тикер без Stop() до Go 1.23 жил в рантайме вечно. С 1.23 недостижимые таймеры и Ticker собираются и без Stop, а вот AfterFunc до срабатывания по-прежнему держит замыкание со всем захваченным.
  7. Не закрытый http.Response.Body держит соединение, буферы чтения и мешает переиспользованию keep-alive.
  8. Замыкание, захватившее лишнее. Колбэк, положенный в долгоживущую структуру, тянет за собой весь контекст запроса.
  9. Растущий контекст: цепочка context.WithValue в долгоживущем контексте.
// 1. Классическая утечка горутины: писатель без читателя
func leak() {
    ch := make(chan int)      // небуферизованный
    go func() { ch <- 42 }()  // навсегда: читателя не будет
    // забыли прочитать -> горутина, её стек и всё захваченное живут вечно
}

// 2. Подслайс
data, _ := os.ReadFile("10mb.bin")
head := data[:16]                            // жив весь 10 МБ массив
head = append([]byte(nil), data[:16]...)     // а так — только 16 байт

// 3. Хвост слайса
func remove(s []*Conn, i int) []*Conn {
    copy(s[i:], s[i+1:])
    s[len(s)-1] = nil          // без этой строки последний Conn недостижим, но жив
    return s[:len(s)-1]
}

// 4. Мапа не отдаёт память
delete(cache, k)               // память под таблицу остаётся
cache = make(map[string]V, 0)  // единственный способ реально освободить
Как отличить утечку от кэша и от «памяти, не отданной ОС»

Три разных диагноза с похожим графиком. Кэш: /gc/heap/live растёт и выходит на плато. Утечка: растёт линейно и не останавливается, обычно пропорционально числу обработанных запросов. Память не отдана ОС: live стабилен, а RSS высокий, потому что scavenger не спешит; это не утечка. Проверка занимает минуту: снять /debug/pprof/heap дважды с интервалом и сравнить inuse_space.

Суть: снять два heap-профиля с интервалом, сравнить их через -base, смотреть на inuse_space (что живо сейчас), а не на alloc_space (что аллоцировалось за всё время). Параллельно всегда проверять /debug/pprof/goroutine — половина «утечек памяти» — это утечка горутин.

Четыре режима heap-профиля

ФлагПоказываетДля чего
-inuse_spaceбайты, живые на момент снятияутечки — основной режим
-inuse_objectsчисло живых объектовмного мелочи vs мало крупного
-alloc_spaceбайты за всё время жизни процессанагрузка на GC, кто мусорит
-alloc_objectsчисло аллокаций за всё времягорячие точки для оптимизации

Разница принципиальная: функция, которая создала терабайт короткоживущих буферов, будет топ-1 в alloc_space и вообще не появится в inuse_space. И наоборот, утечка на 500 МБ может быть незаметна в alloc, если копится медленно. Ищешь утечку → inuse. Ищешь нагрузку на GC → alloc.

Пошагово

# 0. Профиль снимаем после полного цикла GC,
#    иначе в inuse попадёт ещё не собранный мусор.
$ curl -s 'localhost:6060/debug/pprof/heap?gc=1' > h1.pprof
$ sleep 600
$ curl -s 'localhost:6060/debug/pprof/heap?gc=1' > h2.pprof

# 1. Разница снапшотов показывает, что выросло за 10 минут
$ go tool pprof -base h1.pprof h2.pprof
(pprof) top20                 # кто вырос
(pprof) list ^myapp\.Cache    # построчно внутри подозреваемого
(pprof) traces                # полные стеки аллокаций
(pprof) peek NewBuffer        # кто вызывает

# 2. Веб-интерфейс с флеймграфом и графом вызовов
$ go tool pprof -http=:8080 -base h1.pprof h2.pprof

# 3. Параллельно всегда проверяй горутины
$ curl -s 'localhost:6060/debug/pprof/goroutine?debug=1' | head -40
$ go tool pprof -http=:8081 'localhost:6060/debug/pprof/goroutine'

Как читать результат

  • flat — сколько аллоцировала сама функция, cum — она и всё, что она вызвала. Утечку ищут по cum сверху вниз, конкретную строку — по flat.
  • list ФункцияName показывает исходник с байтами напротив каждой строки, и так быстрее всего находится конкретный append или Sprintf.
  • Если в топе runtime.malg или runtime.newproc1, вопрос не к тебе, а к числу горутин.
  • Стеки с chan receive / chan send и большим временем в goroutine?debug=2 прямо указывают на зависшую горутину.
Что сказать про частоту сэмплирования

Heap-профиль сэмплированный: по умолчанию runtime.MemProfileRate = 512 КБ, то есть учитывается примерно одна аллокация на каждые 512 КБ, а числа экстраполируются. Поэтому абсолютные байты в профиле приблизительны, а редкие крупные аллокации могут не попасть. Для точной охоты можно поставить runtime.MemProfileRate = 1 в самом начале main: профилироваться будет всё, но программа заметно замедлится. По этой детали и отличают того, кто читал про pprof, от того, кто им пользовался.

Суть: runtime.SetFinalizer не даёт гарантий ни того, что функция вызовется, ни когда. Это страховка от чужой забывчивости и инструмент диагностики, а не механизм освобождения ресурсов. Освобождение в Go делается через Close() и defer.

Шесть причин не полагаться

  1. Вызова может не быть вообще. При завершении программы финалайзеры не запускаются: если объект дожил до выхода, функция не выполнится.
  2. Время не определено. Между смертью объекта и вызовом может пройти сколько угодно циклов GC. Для файловых дескрипторов и соединений это означает исчерпание лимита ОС задолго до срабатывания.
  3. Объект живёт минимум на цикл дольше. Первый цикл обнаруживает недостижимость и ставит объект в очередь финалайзеров, откуда тот снова становится достижимым (воскрешение). Освободит его только следующий цикл.
  4. Циклы не собираются. Если объекты с финалайзерами ссылаются друг на друга, не освободится ни один и не вызовется ни один финалайзер.
  5. Легко случайно сделать объект бессмертным. Замыкание, захватившее сам obj, держит ссылку на него от корня очереди финалайзеров, и объект не умрёт никогда. Классический баг.
  6. Выполняется в отдельной горутине и последовательно для всех объектов: медленный финалайзер блокирует остальные, а паника в нём валит процесс.
// так нельзя: объект никогда не умрёт
runtime.SetFinalizer(f, func(x *File) {
    log.Printf("closing %v", f)   // замыкание захватило f, ссылка вечная
    syscall.Close(f.fd)
})

// Go 1.24: заменяем на AddCleanup
runtime.AddCleanup(f, func(fd int) { syscall.Close(fd) }, f.fd)

Чем runtime.AddCleanup (Go 1.24) лучше

  • Функция получает отдельный аргумент, а не сам объект, поэтому держать объект ей незачем. Но если замыкание или аргумент всё же ведут к объекту, хотя бы через третью структуру, он не умрёт никогда: паникой рантайм ловит только простые случаи, когда аргумент равен объекту или замыкание захватило его напрямую.
  • Нет воскрешения: объект освобождается в том же цикле, cleanup выполняется после.
  • Циклические структуры собираются нормально.
  • На один объект можно повесить несколько cleanup-ов (SetFinalizer разрешает строго один).
  • Работает с внутренними указателями объекта.
  • С Go 1.25 очередь cleanup-ов разбирается параллельно, несколькими горутинами: раньше всё делала одна, и один медленный обработчик подвешивал остальные.
  • SetFinalizer формально не удалён, но в новом коде считается устаревшим.
Чем это диагностировать: GODEBUG=checkfinalizers=1 (Go 1.25)

Режим ловит те самые два бага, о которых шла речь выше: замыкание финалайзера, захватившее сам объект, и cleanup, из которого достижим объект-владелец. Рантайм печатает размер очереди на каждом цикле и выдаёт WARNING: LIKELY CLEANUP/FINALIZER ISSUES с типом и стеком регистрации, после чего роняет программу фатальной ошибкой. В проде его держать не надо (лишние проверки под stop-the-world на каждом цикле GC и падение процесса на первой находке), а вот интеграционные тесты с этим флагом прогнать стоит: находится то, что иначе живёт в коде годами. Если упомянешь этот флаг на собесе, ответ сразу прозвучит на другом уровне.

Где они всё-таки уместны

  • Подстраховка в библиотеке: так os.File закрывает дескриптор, если пользователь забыл Close(). Именно подстраховка, а не основной путь.
  • Освобождение памяти, выделенной вне Go (cgo, mmap): GC про неё не знает.
  • Диагностика: повесить cleanup, который пишет в лог «объект X собран без Close». Так в тестах и находят утечки ресурсов.
Формулировка для собеса

«Финалайзеры в Go не деструкторы. Деструктор в C++ вызывается детерминированно в конце области видимости, а финалайзер — когда-нибудь и не обязательно. Аналог RAII в Go один: defer x.Close(). Финалайзер оправдан только как страховка библиотеки от забытого Close, для не-Go памяти и для диагностики. С Go 1.24 вместо SetFinalizer пишут runtime.AddCleanup: он не воскрешает объект, и сделать объект бессмертным по ошибке с ним куда труднее.»

3.3Профилирование и бенчмарки

Здесь проверяют, есть ли у тебя процедура: одного знания команд мало. Жалоба «сервис ест память» должна превращаться в последовательность действий, а не в догадку. Отдельно проверяют умение мерить: почти все написанные людьми микробенчмарки измеряют не то, что задумано.

Сначала — четыре слова, без которых дальше будет каша

Практический вопрос здесь один: как увидеть, куда программа на самом деле девает время и память, вместо того чтобы гадать? Догадки обходятся дорого: типичная история оптимизации выглядит как неделя работы над функцией, которая занимала полпроцента. На этот вопрос отвечает профилировщик, а разговор о нём начнём с четырёх слов.

1. Профиль — это статистика по стекам вызовов

Профиль (англ. profile) устроен как таблица вида «вот стек вызовов, вот сколько на него пришлось». Это не лог и не запись событий по порядку. Чего именно пришлось, зависит от профиля: у CPU-профиля это доля времени, у heap-профиля байты и число аллокаций, у block-профиля время ожидания. В отличие от лога, порядок событий в профиле не сохраняется: всё сложено в общую кучу по признаку «одинаковый стек». На вопрос «что было до чего» профиль не ответит, для этого есть трассировка (go tool trace), про неё отдельный раздел ниже.

2. Сэмплирование — считают не каждый раз, а изредка

На сэмплировании (англ. sampling, «выборка») держится всё профилирование. Честно замерять каждый вызов стоило бы дороже, чем сама программа, поэтому профилировщик время от времени делает моментальный снимок и записывает, где программа находится сейчас. В CPU-профиле рантайм сто раз в секунду по сигналу SIGPROF прерывает поток и записывает текущий стек. В heap-профиле делается одна запись примерно на каждые 512 КБ выделенной памяти. Отсюда три следствия, которые надо проговаривать на собесе: профилирование почти бесплатно (единицы процентов оверхеда, поэтому его включают на проде); цифры приблизительные и тем точнее, чем дольше снимаешь; очень быстрая и очень редкая функция может вообще не попасть в выборку.

3. Flame graph — те же стеки, нарисованные прямоугольниками

Flame graph («пламенный график», по-русски обычно говорят «флеймграф») рисует все собранные стеки на одной картинке. Каждый прямоугольник обозначает функцию; его ширина равна доле выборок, в которых эта функция была в стеке, а лежит он поверх того, кто его вызвал (pprof рисует наоборот: корень сверху, вызванные под ним). Спотыкаются обычно на оси: по горизонтали отложено не время. Соседние фреймы pprof ставит по убыванию ширины, классический flamegraph.pl по имени, так что «слева направо» не значит «раньше-позже». Ниже это разобрано на схеме.

4. Инлайн — почему функция может исчезнуть из профиля

При инлайне (англ. inlining, «подстановка») компилятор вместо настоящего вызова вставляет тело маленькой функции прямо в место вызова. Отдельного кадра на стеке у такой функции больше нет, и это сказывается на всей главе. pprof показывает инлайненные функции отдельными фреймами только потому, что компилятор оставляет для них специальную разметку. А микробенчмарк легко меряет инлайненный вариант функции, которая в реальном коде вызывается через интерфейс и не инлайнится никогда. Сам механизм подробно разобран в главе 3.4.

Что сможешь объяснить после этой главы

Что делать по шагам, когда «сервис ест CPU» и когда «сервис ест память». Чем flat отличается от cum и почему диагностика идёт по второму. На какие вопросы отвечает go tool trace, если pprof на них не отвечает в принципе. И почему написанный за пять минут микробенчмарк почти наверняка измеряет не то, что задумано.

Какие бывают профили

ПрофильЧто собираетКакКогда нужен
cpuстеки горутин, которые в этот момент жгут CPUсэмплирование по SIGPROF, 100 Гцвысокий CPU, медленные ответы
heapаллокации: inuse и alloc, байты и объектысэмплирование, 1 из ~512 КБутечки, нагрузка на GC
allocsто же, но по умолчанию в режиме alloc_spaceтот же сэмплеркто мусорит
goroutineстек каждой живой горутиныполный снимок, STW на момент снятияутечка горутин, дедлоки
goroutineleak 1.27стеки только тех горутин, которые заблокированы навсегда: ждут примитив, до которого уже никто не дотянетсяпри снятии запускает свой цикл GC, отсюда точностьутечка горутин — сразу список виновных, без чтения тысяч стеков вручную
blockгде горутины ждут синхронизацию (каналы, мьютексы, WaitGroup)выключен, включается SetBlockProfileRate«тормозит, а CPU простаивает»
mutexкто держит замок и заставляет ждать другихвыключен, SetMutexProfileFractionконтеншен (борьба нескольких горутин за один замок) на общем мьютексе
threadcreateстеки, из-за которых рантайм создал ОС-потоквсегда включёнрост числа M: cgo, блокирующие сисколлы
traceполный лог событий планировщика, GC, сетипо запросу, за интервал«где именно теряется время» на таймлайне
Профиль goroutineleak: новый штатный способ искать утечки горутин

До Go 1.27 утечку горутин искали косвенно: смотрели, как растёт /sched/goroutines, снимали goroutine?debug=2 и глазами искали в тысячах стеков те, что висят на chan receive подозрительно долго. Работало, но требовало опыта и терпения. Профиль goroutineleak делает это сам: он показывает только те горутины, которые заблокированы навсегда. Эвристики тут нет, определение точное: горутина ждёт на канале, мьютексе или WaitGroup, а сам этот примитив уже недостижим из живой части программы, значит, разбудить её физически некому. Пропустить утечку он может: если до примитива дотягивается глобальная переменная или стек работающей горутины, примитив считается живым.

Экспериментом профиль был в Go 1.26 (GOEXPERIMENT=goroutineleakprofile), с Go 1.27 общедоступен: он есть в pprof.Profiles() и в /debug/pprof/goroutineleak вместе с остальными. Ждать очередного цикла GC не нужно: при каждом снятии профиль сам запускает сборку с поиском утечек, а обычный runtime.GC() его не наполняет.

func leak() {
    ch := make(chan int)     // никто не прочитает
    go func() { ch <- 42 }() // навсегда
}

func main() {
    leak()
    runtime.GC()
    p := pprof.Lookup("goroutineleak")
    fmt.Println(p.Count())   // 0 (разбор ниже)
    p.WriteTo(os.Stdout, 1)  // стек виновной горутины
}

// Настоящий вывод go1.27:
// 0
// goroutineleak profile: total 1
// 1 @ 0x104177f18 0x104112224 0x104111e38 0x1041cb918 0x10417e024
// #    0x1041cb917    main.leak.func1+0x27    /app/main.go:12
//
// Count() до первого WriteTo возвращает 0 и после второго runtime.GC() тоже:
// профиль наполняется в момент снятия, а не по циклу GC. После WriteTo
// тот же Count() вернёт 1. На собесе на этой детали и валится ответ
// «проверю утечку счётчиком»: смотреть надо сам профиль.
$ curl -s 'http://localhost:6060/debug/pprof/goroutineleak?debug=1'
goroutineleak profile: total 1
1 @ 0x102320728 0x1022b18a4 0x1022b14b8 0x1024cd698 0x1023277a4
#	0x1024cd697	main.leak.func1+0x27	/app/main.go:12

# в pprof, как обычный профиль
$ go tool pprof -http=:8080 'http://localhost:6060/debug/pprof/goroutineleak'

Как это звучит на собесе: «утечку горутин раньше ловили по счётчику runtime.NumGoroutine и библиотекой goleak в тестах; с Go 1.27 есть штатный профиль goroutineleak, который прямо в проде отдаёт стеки горутин, навсегда вставших на недостижимом примитиве». Такой ответ сразу выделяется: про этот профиль большинство ещё не слышало.

// block и mutex по умолчанию выключены: они не бесплатны, включай по делу
runtime.SetBlockProfileRate(10_000)     // ожидания от 10 мкс пишутся все, короче выборочно (в наносекундах)
runtime.SetMutexProfileFraction(100)    // учитывать 1 событие контеншена из 100
runtime.MemProfileRate = 512 * 1024     // дефолт; 1 = профилировать каждую аллокацию (медленно)

Снять профиль с прода

import (
    "net/http"
    _ "net/http/pprof"   // регистрирует хендлеры в http.DefaultServeMux
)

func main() {
    // отдельный слушатель на localhost, не публичный порт приложения
    go func() { http.ListenAndServe("127.0.0.1:6060", nil) }()
    // ... основной сервер на своём mux ...
}
# CPU за 30 секунд под нагрузкой
$ go tool pprof -http=:8080 'http://localhost:6060/debug/pprof/profile?seconds=30'
# куча прямо сейчас, после форс-цикла GC
$ go tool pprof -http=:8080 'http://localhost:6060/debug/pprof/heap?gc=1'
# горутины: debug=1 агрегирует, debug=2 даёт полные стеки с временем ожидания
$ curl -s 'http://localhost:6060/debug/pprof/goroutine?debug=2' | less
# трейс на 5 секунд
$ curl -so trace.out 'http://localhost:6060/debug/pprof/trace?seconds=5' && go tool trace trace.out
Про безопасность pprof-эндпоинта на собесе спрашивают отдельно
  • Импорт net/http/pprof регистрирует хендлеры в глобальном http.DefaultServeMux. Если основной сервер поднят на nil-хендлере, профили торчат наружу вместе с API. Это классическая находка пентеста.
  • Что утекает: goroutine?debug=2 отдаёт полные стеки, а в них имена функций, структура кода и иногда аргументы; /debug/pprof/cmdline отдаёт командную строку со всеми флагами (нередко с секретами); символы бинаря выдают версии зависимостей.
  • Что ломается: profile?seconds=300 держит CPU-профилирование пять минут, heap?gc=1 форсирует полный цикл GC, goroutine делает STW на снятие снимка. Любым из них можно устроить DoS.
  • Как правильно: отдельный слушатель на 127.0.0.1 или на внутреннем интерфейсе, доступ через kubectl port-forward; либо свой ServeMux с явной регистрацией pprof.Index и аутентификацией; либо сборка с build tag, включающим pprof только в отладочных образах.

Метки горутин: чей это запрос

Профиль отвечает на вопрос «какая функция жжёт CPU». На сервисе почти сразу нужен другой разрез: «какой обработчик», «какой тенант», «фон или онлайн». Для этого в runtime/pprof есть метки (labels): пары ключ-значение, которые вешаются на горутину. Метки наследуются: горутина, запущенная внутри помеченной, получает те же метки, поэтому достаточно поставить их один раз на границе запроса.

import "runtime/pprof"

func handler(w http.ResponseWriter, r *http.Request) {
    labels := pprof.Labels("handler", "createOrder", "tenant", tenantOf(r))
    pprof.Do(r.Context(), labels, func(ctx context.Context) {
        createOrder(ctx, r)      // всё, что здесь запустится, унаследует метки
    })
}
# какие метки вообще есть в профиле
$ go tool pprof -tags cpu.pprof
# только выборки одного обработчика
$ go tool pprof -tagfocus='handler=createOrder' -http=:8080 cpu.pprof
# метки видны и в goroutine-профиле:
$ curl -s 'http://localhost:6060/debug/pprof/goroutine?debug=1' | grep labels
# labels: {"handler":"createOrder", "tenant":"acme"}

Стоят метки немного: набор хранится у горутины и копируется при pprof.Do, поэтому ставить их на границе запроса нормально, а внутри горячего цикла нет. Второе ограничение касается кардинальности: каждое уникальное сочетание значений режет профиль на отдельную группу, так что «handler» и «tenant» в метках уместны, а идентификатор запроса раздувает профиль (у него значение уникально для каждого вызова).

Go 1.27: метки видно прямо в трейсбеке при панике

Раньше метки жили только внутри профилей. С Go 1.27 (если в go.mod стоит go 1.27 или новее) рантайм печатает их в заголовке горутины в трейсбеке, и по падению сразу видно, что именно горутина обрабатывала:

panic: boom

goroutine 1 [running] {handler: createOrder, request_id: "abc-123"}:
main.main()
	/app/main.go:16 +0xfc

До 1.27 в этой строке были только номер горутины и её состояние. По ним нельзя было понять, на каком запросе всё сломалось, и приходилось искать соседние строки лога по времени. Особенно выручает связка с GOTRACEBACK=all, когда печатаются все горутины: видно виновную и заодно то, чем были заняты остальные. Отключается GODEBUG=tracebacklabels=0. Одна ловушка: pprof.Do возвращает прежние метки в defer, а при панике defer-ы отрабатывают до печати трейсбека. Паника прямо в функции, переданной в Do, выйдет без меток, и в логе net/http о панике в обработчике их тоже не будет. Видны они у горутин, запущенных внутри Do, при фатальных ошибках рантайма вроде concurrent map writes и у остальных горутин в выводе с GOTRACEBACK=all. А request_id в метках здесь как раз к месту, в отличие от профилирования.

Как читать профиль

$ go tool pprof cpu.pprof
(pprof) top10            # топ по flat
(pprof) top10 -cum       # топ по cum: где «висит» всё поддерево
(pprof) list encodeRow   # исходник функции с цифрами напротив строк
(pprof) peek mallocgc    # кто вызывает и кого вызывает
(pprof) traces           # сырые стеки с весами
(pprof) web              # SVG-граф вызовов (нужен graphviz)
(pprof) png > cpu.png

Две колонки надо различать: flat считает время (или байты) в самой функции, без вызванных ею, а cum добавляет всё поддерево. Диагностика идёт сверху вниз по cum («в каком поддереве проблема»), а конкретную строку находишь по flat и list.

Отдельно про веб-интерфейс: go tool pprof -http=:8080 … поднимает локальный сервер и открывает те же данные в браузере. С Go 1.26 стартовой страницей стал flame graph, а не граф вызовов, как раньше (граф никуда не делся, он в меню View). И это правильный дефолт: по флеймграфу ответ «где горячо» читается за секунды, а граф вызовов на большом сервисе превращается в стену из сотен узлов.

Flame graph профиля CPU Ширина фрейма = доля выборок, в которых он был в стеке. Ось X — НЕ время: pprof ставит фреймы по убыванию ширины. Высота = глубина стека. Ищем ШИРОКИЕ ПЛАТО в верхней части: там процессор реально работает, а не просто проходит мимо. листья корень mallocgc reflect.Value db.Query json.Marshal log.Print handleOrder handleHealth http.HandlerFunc.ServeHTTP gcBgMarkWorker root плато наверху: 12% CPU в mallocgc gcBgMarkWorker шириной в 20% — проблема не в коде функции, а в темпе аллокаций flat = ширина самого фрейма, cum = ширина вместе со всем, что над ним
Flame graph. Читается снизу вверх: корень внизу, листья сверху; pprof рисует то же вверх ногами, с корнем root наверху. Искать надо не пики, а плато: широкий фрейм в верхней части означает, что процессор проводит время именно там. За узким высоким «шпилем» стоит глубокий, но дешёвый стек.

Алгоритм: «сервис ест много CPU»

  1. Убедиться, что это сервис. top/kubectl top pod: точно ли наш процесс, точно ли user-time, а не iowait и не сосед по ноде.
  2. Снять CPU-профиль под нагрузкой, 30 секунд: go tool pprof -http=:8080 '...profile?seconds=30'. Профиль без нагрузки бесполезен.
  3. Посмотреть top -cum и флеймграф. Три типовых картины:
    • широко runtime.gcBgMarkWorker, mallocgc, memmove: значит, проблема в аллокациях, а не в CPU, переходи к heap-профилю;
    • широко runtime.mapaccess, runtime.growslice, reflect.*, encoding/json: виноваты структуры данных и сериализация;
    • широко runtime.futex, lock2, selectgo: упёрлись в синхронизацию, снимай mutex/block и trace.
  4. list по подозреваемой функции, чтобы найти конкретные строки.
  5. Написать бенчмарк, воспроизводящий горячий путь, зафиксировать базу.
  6. Починить, сравнить benchstat-ом, выкатить, снять профиль снова.
  7. Если код уже оптимален, проверить уровнем выше, не делаем ли мы вообще лишнюю работу (кэш, батчинг, отказ от сериализации, другой формат).

Алгоритм: «сервис ест много памяти»

  1. Разделить три диагноза. Растёт /gc/heap/live (реально больше живых данных) или только RSS (память не отдана ОС)? Во втором случае утечки нет.
  2. Проверить горутины первым делом: /debug/pprof/goroutine?debug=1. Если их десятки тысяч и число растёт, утекают горутины, а не память, и искать надо незакрытые каналы и запросы без таймаута.
  3. Два heap-снапшота с интервалом и -base: go tool pprof -base h1 h2, режим inuse_space.
  4. Разделить утечку и кэш: у кэша рост выходит на плато, а утечка растёт линейно, пропорционально числу запросов.
  5. list и traces по подозреваемой функции: кто держит ссылку.
  6. Проверить типовой список: глобальные мапы, подслайсы большого массива, забытые Ticker, незакрытые resp.Body, хвост слайса после удаления.
  7. Если утечки нет, а памяти всё равно много, значит, такой профиль нагрузки: снижай аллокации, ставь GOMEMLIMIT, крути GOGC.

go tool trace: то, чего pprof не покажет

pprof отвечает на вопрос «где тратится CPU», а trace на вопрос «почему ничего не происходит». Он пишет каждое событие рантайма с наносекундной точностью и раскладывает их на таймлайн.

  • Goroutine analysis, главный экран: по каждой горутине видно, сколько она выполнялась и сколько ждала планировщик, сеть, синхронизацию, GC.
  • Scheduler latency: сколько горутина стояла в очереди готовых, не получая свободного P. Рядом Synchronization blocking: сколько ждала мьютексы и каналы.
  • GC-фазы прямо на таймлайне: видно STW, mark assist конкретных горутин, работу воркеров.
  • Network blocking / Syscall profile: сколько ждали сеть и сисколлы.
  • Пользовательские регионы: trace.WithRegion, trace.NewTask, trace.Log. Ими размечаешь свой код, и он появляется на той же оси.
import "runtime/trace"

f, _ := os.Create("trace.out")
trace.Start(f)
defer trace.Stop()

// размечаем свой код
ctx, task := trace.NewTask(ctx, "processOrder")
defer task.End()
trace.WithRegion(ctx, "validate", func() { validate(o) })
Когда брать trace вместо pprof

Если «латентность плавает, а CPU низкий», почти всегда нужен trace. Типовые находки: горутина ждала P (мало GOMAXPROCS или один тяжёлый воркер занял всех), латентность запросов совпадает с фазой маркировки (mark assist), длинный сисколл заблокировал M, вся работа села на один P из-за неудачного разделения на горутины. С Go 1.21+ трассировка стала заметно дешевле (1–2% оверхеда), поэтому её реально включать на проде на несколько секунд. С Go 1.22 появился flight recorder (golang.org/x/exp/trace, в 1.25 переехал в runtime/trace): кольцевой буфер последних секунд трейса, который сбрасывается по событию. Такой буфер выручает при редких всплесках латентности.

Устройство бенчмарка

func BenchmarkEncode(b *testing.B) {
    payload := makePayload()   // готовим данные до цикла
    b.ReportAllocs()           // всегда: аллокации важнее наносекунд
    b.ResetTimer()             // если подготовка была дорогой

    for i := 0; i < b.N; i++ {
        sink, _ = Encode(payload)   // sink на уровне пакета, чтобы вызов не выкинули
    }
}

var sink []byte   // «слив»: мешает компилятору удалить вызов как мёртвый код
// Go 1.24: вот так правильно
func BenchmarkEncode(b *testing.B) {
    payload := makePayload()   // не попадёт в замер: первый b.Loop() сбросит таймер
    b.ReportAllocs()

    for b.Loop() {
        Encode(payload)        // sink не нужен: аргументы и результат компилятор не выкинет
    }
}

b.N задаёт число итераций текущего прогона, а не «сколько раз запустить тест». testing запускает функцию несколько раз, каждый раз увеличивая b.N, пока прогон не займёт -benchtime (по умолчанию 1 секунда). Отчёт печатается по последнему прогону: ns/op = elapsed / b.N.

Как testing подбирает b.N Функция запускается заново на каждом шаге. Все прогоны кроме последнего выбрасываются — это и есть прогрев. шаг 1 b.N = 1 прошло 340 нс это < 1 с увеличиваем шаг 2 b.N = 100 прошло 34 мкс это < 1 с увеличиваем шаг 3 b.N = 10000 прошло 3.4 мс это < 1 с увеличиваем шаг 4 b.N = 1000000 прошло 0.34 с это < 1 с увеличиваем шаг 5 b.N = 3529411 прошло 1.2 с хватит 340 ns/op Как считается следующее N N_next = N x (benchtime / elapsed) x 1.2 без округления (округлять до круглых чисел перестали в Go 1.13), но не больше чем в 100 раз за шаг. Верхний предел — 1e9 итераций. Отчёт печатается по последнему прогону. Отсюда правило: НИКОГДА не делай дорогую подготовку внутри цикла по b.N — она умножится на миллионы. Go 1.24: for b.Loop() { ... } функция вызывается один раз, N растёт внутри Loop; результаты вызовов в теле живы; таймер сам исключает подготовку.
Подбор b.N. Правильный ответ: «b.N показывает, сколько раз пройдёт цикл, и testing подбирает его так, чтобы прогон занял benchtime». Добивка: в цикле по b.N функция вызывается несколько раз целиком, поэтому глобальное состояние между прогонами надо сбрасывать; с for b.Loop() она вызывается один раз.

Dead code elimination и почему b.Loop() это решает

// Врёт: компилятор видит, что результат не используется, и выкидывает вызов.
// Бенчмарк покажет 0.25 ns/op, столько стоит пустой цикл.
func BenchmarkBad(b *testing.B) {
    for i := 0; i < b.N; i++ {
        Hash("hello")
    }
}

// Старый рабочий приём: «слив» в пакетную переменную
var sinkU uint64
func BenchmarkOld(b *testing.B) {
    var acc uint64
    for i := 0; i < b.N; i++ {
        acc = Hash("hello")
    }
    sinkU = acc     // компилятор обязан сохранить: переменная видна снаружи
}

// Go 1.24: результаты вызовов в теле b.Loop() компилятор не выкидывает.
// Литерал не спасёт: с Go 1.26 Hash("hello") инлайнится и считается
// при компиляции, поэтому вход берём из переменной пакета
var input = "hello"

func BenchmarkNew(b *testing.B) {
    for b.Loop() {
        Hash(input)
    }
}
Go 1.26: b.Loop больше не мешает инлайну

У первой реализации b.Loop() была расплата. Чтобы тело цикла нельзя было выкинуть, компилятор удерживал живыми значения внутри него и заодно переставал инлайнить вызовы в теле цикла. Получалась подмена: бенчмарк честно мерил функцию, но её неинлайненный вариант, а в проде она инлайнилась и вела себя иначе, потому что после подстановки включаются остальные оптимизации и escape analysis нередко оставляет объект на стеке. Расходились обе цифры, и ns/op, и allocs/op.

В Go 1.26 это починили: тело for b.Loop() компилируется как обычный код, инлайн работает. Проверяется одной командой. В выводе должно быть inlining call to … на строке из тела цикла:

$ go test -run='^$' -bench=Add -gcflags='-m' .
./lib.go:3:6: can inline Add
./lib_test.go:7:12: inlining call to testing.(*B).Loop
./lib_test.go:8:6: inlining call to Add        # тело цикла инлайнится, как в обычном коде
МетодЧто делает
b.ReportAllocs()добавляет B/op и allocs/op в вывод (то же, что флаг -benchmem)
b.ResetTimer()обнуляет таймер и счётчики: подготовка не попадёт в измерение
b.StopTimer() / b.StartTimer()пауза вокруг дорогой подготовки внутри цикла (сама пауза не бесплатна)
b.Loop() 1.24цикл с автоматическим ResetTimer и защитой от DCE — новый рекомендуемый способ
b.RunParallelгоняет тело в GOMAXPROCS горутин: измеряет контеншен
b.ReportMetric(v, "unit")своя метрика в отчёте, например rows/s
b.SetBytes(n)добавляет MB/s — удобно для кодеков и IO
# прогнать 10 раз, чтобы было что статистически сравнивать
$ go test -run='^$' -bench=Encode -benchmem -count=10 > old.txt
# ... правки ...
$ go test -run='^$' -bench=Encode -benchmem -count=10 > new.txt
$ go install golang.org/x/perf/cmd/benchstat@latest
$ benchstat old.txt new.txt
goos: darwin
goarch: arm64
pkg: enc
cpu: Apple M4 Pro
          │   old.txt   │               new.txt               │
          │   sec/op    │   sec/op     vs base                │
Encode-14   1.130µ ± 2%   1.012µ ± 4%  -10.49% (p=0.000 n=10)

          │   old.txt    │                new.txt                │
          │     B/op     │     B/op      vs base                 │
Encode-14   1.000Ki ± 0%   0.000Ki ± 0%  -100.00% (p=0.000 n=10)

          │  old.txt   │               new.txt               │
          │ allocs/op  │ allocs/op   vs base                 │
Encode-14   1.000 ± 0%   0.000 ± 0%  -100.00% (p=0.000 n=10)
Почему benchstat, а не «глазами»

Один прогон бенчмарка даёт одно измерение шумной величины. benchstat берёт -count=N прогонов, считает медиану, разброс и p-value по критерию Манна-Уитни. Если p > 0.05, он честно печатает ~, то есть «разницы нет». Фраза «ускорил на 3%» без benchstat на собесе звучит как «я померил шум».

Семь способов, которыми микробенчмарк врёт
  1. Dead code elimination: результат не используется, и вызов удалён.
  2. Constant folding. Аргумент константный, и компилятор посчитал всё на этапе компиляции.
  3. Инлайнинг: в бенчмарке функция инлайнится, а в реальном коде (вызов через интерфейс) нет.
  4. Идеальный кэш. Одни и те же данные миллион раз лежат в L1, а в проде за ними приходится ходить в память. Реальная разница легко доходит до 10 раз.
  5. Идеальный предсказатель ветвлений: одинаковый вход делает все ветки предсказуемыми.
  6. Подготовка внутри цикла: меришь makePayload, а не Encode. И наоборот: sync.Pool и прогретые буферы дают ноль аллокаций на второй итерации, которых в проде не будет.
  7. Среда: turbo boost, thermal throttling, шумный CI, соседи по ноде, разный GOMAXPROCS. Отсюда -count=10 и benchstat.
Дисциплина измерений
  • -run='^$', чтобы не запускать тесты вместе с бенчами.
  • -benchmem или b.ReportAllocs() ставь всегда.
  • -count=10 + benchstat обязательны, если делаешь вывод.
  • Варьируй вход: b.Run("size=1KB", ...), случайные данные, реальные payload-ы из прода.
  • Последнее слово не за бенчмарком, а за профилем прода после выката. Микробенчмарк доказывает, что функция стала быстрее; он не доказывает, что стало быстрее приложение.

Метрики рантайма

ИнструментЧто даётЦенаКогда
runtime.MemStats + ReadMemStatsплоская структура: HeapAlloc, HeapInuse, NumGC, PauseNs, Mallocs, Freesделает STW на время снятиялегаси; разово в отладке
runtime/metrics 1.16+версионированные метрики с единицами измерения, включая гистограммы паузбез STWштатный экспорт в проде
expvarотдаёт JSON на /debug/vars, включая memstats и cmdlineдёшево, но тянет ReadMemStatsпростые случаи; закрывать так же, как pprof
debug.ReadGCStatsистория пауз GC и распределение по квантилямдёшевоесли нужны только паузы
debug.SetTraceback, GOTRACEBACKподробность стека при паденииразбор паник в проде
// runtime/metrics: гистограмма пауз и live heap в Prometheus
var samples = []metrics.Sample{
    {Name: "/sched/pauses/total/gc:seconds"},    // бывшая /gc/pauses:seconds, устарела с Go 1.22
    {Name: "/gc/heap/live:bytes"},
    {Name: "/sched/latencies:seconds"},          // задержка планировщика, её зря недооценивают
    {Name: "/sched/goroutines/runnable:goroutines"}, // Go 1.26: готовы бежать, но P не досталось
    {Name: "/sched/threads/total:threads"},          // Go 1.26: сколько ОС-потоков держит рантайм
}

func collect() {
    metrics.Read(samples)
    h := samples[0].Value.Float64Histogram()
    live := samples[1].Value.Uint64()
    _ = h; _ = live
}

// список всех доступных метрик с описаниями
for _, d := range metrics.All() { fmt.Println(d.Name, "-", d.Description) }

// go1.27: в списке 107 метрик, первые восемь имён (без описаний):
//   /cgo/go-to-c-calls:calls
//   /cpu/classes/gc/mark/assist:cpu-seconds
//   /cpu/classes/gc/mark/dedicated:cpu-seconds
//   /cpu/classes/gc/mark/idle:cpu-seconds
//   /cpu/classes/gc/pause:cpu-seconds
//   /cpu/classes/gc/total:cpu-seconds
//   /cpu/classes/idle:cpu-seconds
//   /cpu/classes/scavenge/assist:cpu-seconds
// Список отсортирован по имени и растёт от версии к версии, поэтому имена метрик
// и проверяют через metrics.All(), а не хардкодят.
Go 1.26: метрики планировщика стали подробнее

До 1.26 про горутины была ровно одна цифра: сколько их живых (/sched/goroutines:goroutines, тогда то же самое, что runtime.NumGoroutine(); с 1.26 метрика считает и системные горутины рантайма, так что выходит больше). Счётчик показывал «тысяча горутин», но не говорил, чем они заняты. В Go 1.26 добавили разбивку по состояниям и метрики потоков:

  • /sched/goroutines/running:goroutines: горутины, которые прямо сейчас выполняются на ядре; их не больше /sched/gomaxprocs:threads.
  • /sched/goroutines/runnable:goroutines: такие горутины готовы бежать, но им не досталось P. Из четырёх эта самая ценная: устойчиво ненулевая runnable при runningGOMAXPROCS означает, что процессора не хватает, и объясняет выросшую /sched/latencies.
  • /sched/goroutines/waiting:goroutines: ждут ввод-вывод или синхронизацию. Если эта метрика растёт, а running нет, упёрлись не в CPU, а в чужой сервис или в замок.
  • /sched/goroutines/not-in-go:goroutines: сидят в сисколле или в cgo-вызове.
  • /sched/goroutines-created:goroutines считает горутины, созданные за всё время работы, и по производной виден темп, а не только остаток.
  • /sched/threads/total:threads показывает, сколько ОС-потоков (M) держит рантайм. Раньше это узнавали только из threadcreate-профиля; если число растёт, ищи блокирующие сисколлы или cgo.

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

Вопросы

6
Суть: восемь профилей, из которых block и mutex выключены по умолчанию, а goroutineleak появился в Go 1.27. С прода снимаются HTTP-ручкой из net/http/pprof, поднятой на отдельном локальном порту. Читаются через top -cumlist → флеймграф.

Профили

  • cpu: сэмплирование стеков по SIGPROF 100 раз в секунду. Снимается за интервал, обязательно под нагрузкой.
  • heap про аллокации, у него четыре режима: inuse_space, inuse_objects, alloc_space, alloc_objects. Сэмплирование по умолчанию раз в 512 КБ.
  • goroutine даёт полный снимок стеков всех живых горутин. Долго был главным инструментом против утечки горутин.
  • goroutineleak 1.27 показывает только те горутины, которые заблокированы навсегда: ждут канал, мьютекс или WaitGroup, до которого из живой части программы уже не дотянуться. Эвристики «висит подозрительно долго» тут нет, недостижимость считает сборщик мусора. Экспериментом был в 1.26, с 1.27 лежит в pprof.Profiles() и на /debug/pprof/goroutineleak. На практике раньше в проде смотрели растущий счётчик горутин и вручную разбирали goroutine?debug=2, а теперь есть готовый список виновных.
  • block: где горутины ждали синхронизацию; ожидания дольше заданного rate пишутся все, короткие выборочно. Включается runtime.SetBlockProfileRate.
  • mutex ловит контеншен: кто заставил других ждать. Включается runtime.SetMutexProfileFraction.
  • threadcreate объясняет, почему рантайм создал ОС-поток (cgo, блокирующие сисколлы).
  • trace вообще не профиль, а полный лог событий на таймлайне.

Как снять с прода

import _ "net/http/pprof"
// отдельный слушатель на loopback, а не публичный порт приложения
go func() { log.Println(http.ListenAndServe("127.0.0.1:6060", nil)) }()
$ kubectl port-forward pod/api-7f9 6060:6060
$ go tool pprof -http=:8080 'http://localhost:6060/debug/pprof/profile?seconds=30'
$ go tool pprof -http=:8080 'http://localhost:6060/debug/pprof/heap?gc=1'
$ curl -s 'http://localhost:6060/debug/pprof/goroutine?debug=1' | head -30

Как читать

top -cum отвечает на вопрос «в каком поддереве проблема», top (по flat) на вопрос «в какой конкретно функции», list Func на «в какой строке», peek на «кто это вызывает». Флеймграф: ширина = доля выборок, высота = глубина стека, а ось X к времени отношения не имеет. Ищем широкие плато на стороне листьев (в pprof это низ картинки, корень там сверху): там процессор реально работает; узкий длинный шпиль означает глубокий, но дешёвый стек.

Безопасность: почти обязательная добивка

Импорт net/http/pprof вешает хендлеры в глобальный http.DefaultServeMux. Если основной сервер стартовал с nil-хендлером, /debug/pprof/* торчит в интернет вместе с API. Оттуда утекают полные стеки, имена функций, /debug/pprof/cmdline с флагами и секретами; плюс любой желающий может запустить profile?seconds=600 или heap?gc=1 и получить готовый DoS. Правильно: отдельный listener на 127.0.0.1 + port-forward, либо свой ServeMux с авторизацией, либо build tag для отладочных образов.

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

Много CPU

  1. Подтвердить: kubectl top pod, user-time именно нашего процесса, не iowait, не сосед по ноде, не throttling из-за CPU-лимита (container_cpu_cfs_throttled).
  2. Снять CPU-профиль под нагрузкой: profile?seconds=30.
  3. top -cum + флеймграф. Три диагноза по тому, что широкое:
    • gcBgMarkWorker, mallocgc, memmove → это аллокационная проблема, переходим к heap-профилю в режиме alloc_space;
    • encoding/json, reflect, mapaccess, growslice → сериализация и структуры данных;
    • futex, lock2, selectgo, chanrecv → синхронизация: снимаем mutex, block, trace.
  4. list по функции, чтобы увидеть конкретные строки.
  5. Написать бенчмарк на горячий путь, зафиксировать базу (-count=10).
  6. Починить, сравнить benchstat, выкатить, снять профиль ещё раз.
  7. Если код уже оптимален, подняться на уровень выше: кэш, батчинг, отказ от лишней работы, другой формат обмена. Быстрее всего выполняется работа, которую не делают.

Много памяти

  1. Разделить три диагноза: растёт /gc/heap/live, или растёт только RSS (память просто не отдана ОС, и это не утечка), или это нормальный профиль нагрузки.
  2. Сразу проверить горутины: goroutine?debug=1. Растущее число означает утечку горутин, и дальше ищи каналы без читателя и запросы без таймаута.
  3. Два heap-снапшота с интервалом 10–30 минут, сравнить: go tool pprof -base h1.pprof h2.pprof, режим inuse_space.
  4. Отличить кэш от утечки: кэш выходит на плато, утечка растёт линейно от числа запросов.
  5. list / traces по подозреваемому: кто именно держит ссылку.
  6. Пройтись по типовому списку: глобальные мапы без вытеснения, подслайсы большого массива, забытые Ticker, незакрытые resp.Body, хвост слайса, жирные замыкания.
  7. Если утечки нет, снижать аллокации, ставить GOMEMLIMIT, крутить GOGC. Именно в таком порядке.
Что интервьюер хочет услышать

Не список команд, а ветвление: «если в профиле широкий gcBgMarkWorker, то дело не в CPU, а в аллокациях, и лечится оно в другом месте». И честную границу: «сначала я подтверждаю проблему метрикой, потому что половина обращений „сервис тормозит“ оказывается проблемой соседнего сервиса или сети». Ответ «я бы посмотрел pprof» без ветвления считается слабым.

Суть: pprof показывает, где горит CPU; trace показывает, почему ничего не происходит. Это полный лог событий рантайма с наносекундной точностью, разложенный на таймлайн: планировщик, GC, сеть, сисколлы, блокировки.

Что видно

  • Goroutine analysis раскладывает время каждой горутины: выполнение, ожидание планировщика, ожидание сети, синхронизации, GC, сисколла. Это главный экран.
  • Таймлайн по P: какая горутина на каком процессоре и когда; сразу видно, если вся работа села на один P.
  • Фазы GC прямо на оси: STW-паузы, работа mark-воркеров, mark assist конкретных горутин.
  • Scheduler latency: горутина готова, а P нет. Так проявляется нехватка GOMAXPROCS или длинные неотдающие участки.
  • Syscall / Network blocking показывает, сколько ждали ОС и сеть.
  • Пользовательские регионы и задачи: trace.NewTask, trace.WithRegion, trace.Log выводят свой код на ту же ось.
$ curl -so trace.out 'http://localhost:6060/debug/pprof/trace?seconds=5'
$ go tool trace trace.out       # откроет браузер

Когда именно

  • «Латентность плавает, а CPU низкий»: почти всегда это случай для trace.
  • Подозрение, что латентность коррелирует с циклами GC (mark assist).
  • Подозрение на голодание горутин или на неудачную нарезку работы.
  • Долгий сисколл / cgo-вызов, который блокирует M.
  • Проверка, что worker pool реально загружает все ядра.
Цена и flight recorder

Трассировка не бесплатна, но в Go 1.21 оверхед упал до 1–2%, а в 1.22 её переписали, так что включать её на проде на несколько секунд реально. Файлы получаются большими: 5 секунд нагруженного сервиса занимают десятки мегабайт, и дольше 10–20 секунд снимать бессмысленно. Отдельно назови flight recorder (Go 1.22 в golang.org/x/exp/trace, с Go 1.25 в runtime/trace): кольцевой буфер последних секунд трейса, который сбрасывается на диск по внешнему событию. Он ловит редкие всплески латентности, которые «снятием трейса вручную» не поймать.

Суть: b.N — число итераций текущего прогона. testing запускает функцию несколько раз с растущим b.N, пока прогон не займёт -benchtime (1 с), и печатает результат последнего прогона. С Go 1.24 вместо ручного цикла пишут for b.Loop().

Как подбирается b.N

  1. Первый прогон с b.N = 1.
  2. Если прогон занял меньше benchtime, следующее N считается как N × (benchtime / elapsed) × 1.2 без округления (круглые числа были до Go 1.13) и не растёт больше чем в 100 раз за шаг.
  3. Так до тех пор, пока прогон не дотянет до benchtime (потолок 1e9 итераций).
  4. Отчёт печатается по последнему прогону: ns/op = elapsed / b.N. Все предыдущие прогоны выбрасываются, они и служат прогревом.

Практическое следствие для цикла по b.N: функция вызывается целиком несколько раз. Значит, глобальное состояние между прогонами надо сбрасывать, а дорогую подготовку нельзя делать внутри цикла, иначе она умножится на миллионы итераций.

Dead code elimination

// врёт: результат не используется, компилятор выкидывает вызов, получаем 0.3 ns/op
for i := 0; i < b.N; i++ { Hash("x") }

// старый рабочий приём: «слив» в пакетную переменную
var sink uint64
func BenchmarkHash(b *testing.B) {
    var acc uint64
    for i := 0; i < b.N; i++ { acc = Hash("x") }
    sink = acc
}

// Go 1.24: тело b.Loop() компилятор выкинуть не вправе
func BenchmarkHash(b *testing.B) {
    payload := makePayload()   // не попадёт в измерение: таймер стартует внутри Loop
    b.ReportAllocs()
    for b.Loop() { Hash(payload) }
}

b.Loop() решает сразу три вещи: результаты вызовов в теле не выкидываются оптимизатором, функция бенчмарка вызывается один раз, и подготовка до цикла автоматически исключается из времени (не нужен ResetTimer). От свёртки констант он не спасает: с Go 1.26 вызов с литералом в аргументах инлайнится и может посчитаться при компиляции, так что вход бери из переменной. И сам цикл не дешевле ручного: счётчик лежит в полях структуры B, и пустая итерация выходит в разы дороже, чем у цикла по b.N.

К ответу хорошо добавить уточнение по версиям: в Go 1.24–1.25 защита от DCE побочно выключала инлайн вызовов внутри тела цикла, и бенчмарк мерил неинлайненный вариант функции, так что расходились и ns/op, и allocs/op. С Go 1.26 это исправлено: тело for b.Loop() компилируется как обычный код, инлайн работает.

Инструменты вокруг

  • b.ReportAllocs() добавляет B/op и allocs/op, как флаг -benchmem. Ставить всегда: аллокации важнее наносекунд, потому что за ними стоит будущая работа GC.
  • b.ResetTimer() обнуляет таймер после подготовки (при b.Loop() не нужен).
  • b.StopTimer()/b.StartTimer() ставят вокруг дорогой подготовки внутри цикла; сама пара стоит десятки микросекунд (каждый вызов делает ReadMemStats с остановкой мира), и для микроопераций это уже искажение.
  • b.RunParallel гоняет тело в GOMAXPROCS горутин и так измеряет контеншен.
  • b.SetBytes и b.ReportMetric добавляют MB/s и свои единицы.
$ go test -run='^$' -bench=Encode -benchmem -count=10 > old.txt
$ go test -run='^$' -bench=Encode -benchmem -count=10 > new.txt
$ benchstat old.txt new.txt      # golang.org/x/perf/cmd/benchstat
Про benchstat одной фразой

Один прогон даёт одно измерение шумной величины. benchstat берёт -count=N прогонов, считает медиану, разброс и p-value; если разница статистически незначима, он печатает ~. Кто говорит «стало быстрее на 3%» без benchstat, тот говорит про шум, и на собесе это слышно.

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

Что искажает результат

  1. Dead code elimination. Результат не используется, поэтому вызова просто нет. Симптом: подозрительно круглые доли наносекунды.
  2. Constant folding. Аргумент задан литералом, и компилятор посчитал всё на этапе компиляции. Лечится входом из переменной, которую компилятор не видит насквозь.
  3. Инлайнинг, которого не будет в проде. В бенчмарке функция вызывается напрямую и инлайнится; в приложении она спрятана за интерфейсом: вызов косвенный, инлайна нет, и escape analysis решает иначе.
  4. Идеальная локальность. Один и тот же вход миллион раз лежит в L1. В проде это промах в память: 1 нс против 80 нс. Разница в разы, и она не в коде.
  5. Идеальный предсказатель ветвлений. Одинаковые данные делают все if бесплатными, а на реальном распределении получаешь mispredict на каждой второй итерации.
  6. Прогретое состояние. sync.Pool, преаллоцированные буферы, заполненные мапы дают ноль аллокаций со второй итерации. В проде каждый запрос и есть первая итерация.
  7. Подготовка внутри цикла: измеряешь генератор данных, а не функцию. И обратная ошибка: StopTimer/StartTimer на каждой итерации сами по себе дороже измеряемой операции.
  8. Среда. Turbo boost, тепловой троттлинг, шумный CI, соседи по ноде, разный GOMAXPROCS, включённый профайлер. Отсюда обязательные -count=10 и benchstat.
  9. Не тот масштаб. Функцию ускорили в 10 раз, но она занимала 0.5% профиля, и выигрыш составил 0.45%. Закон Амдала никто не отменял.
Самый частый реальный случай

Кандидат «ускоряет» функцию, гоняя её на одном и том же коротком входе. В проде входы разной длины, и после оптимизации на маленьких данных большие стали медленнее. Правильно гонять b.Run("size=64", ...) / "size=4K" / "size=1M" и реальные payload-ы, снятые с прода. И финальная проверка всегда одна: профиль прода после выката. Бенчмарк доказывает, что функция стала быстрее; он не доказывает, что стало быстрее приложение.

Суть: три поколения одного и того же. MemStats — старое и делает STW; expvar — обёртка, отдающая JSON на /debug/vars; runtime/metrics (Go 1.16+) — современный, без STW, с версионированными именами и гистограммами. Для нового кода — только третий.

runtime.MemStats

Плоская структура: HeapAlloc (живые + ещё не подметённые байты), HeapInuse, HeapReleased (возвращено ОС), NumGC, PauseTotalNs, кольцо PauseNs[256], счётчики Mallocs и Frees. Читается через runtime.ReadMemStats, и это его главный минус: вызов останавливает мир на время копирования. На сервисе с тысячами горутин и экспортом раз в секунду мониторинг сам начинает создавать паузы.

runtime/metrics

samples := []metrics.Sample{
    {Name: "/gc/heap/live:bytes"},               // живые данные
    {Name: "/gc/heap/goal:bytes"},               // цель pacer-а
    {Name: "/sched/pauses/total/gc:seconds"},    // гистограмма пауз
    {Name: "/cpu/classes/gc/total:cpu-seconds"}, // доля CPU на GC
    {Name: "/memory/classes/total:bytes"},       // минус heap/released: то, что ограничивает GOMEMLIMIT
    {Name: "/sched/goroutines:goroutines"},      // число горутин
    {Name: "/sched/latencies:seconds"},          // задержка планировщика
    {Name: "/sched/goroutines/runnable:goroutines"}, // Go 1.26: стоят в очереди за P
    {Name: "/sched/threads/total:threads"},          // Go 1.26: число ОС-потоков рантайма
}
metrics.Read(samples)
  • Имена версионированные, единицы измерения зашиты в имя после двоеточия.
  • Кроме счётчиков, есть гистограммы, так что p99 пауз можно считать честно.
  • metrics.All() перечисляет всё доступное в текущей версии Go с описаниями.
  • Go 1.26 добавил разбивку по планировщику: /sched/goroutines/running, /runnable, /waiting, /not-in-go, счётчик созданных /sched/goroutines-created и число ОС-потоков /sched/threads/total:threads. До этого была одна цифра «сколько горутин живо», и по ней нельзя было отличить «ждут базу» от «стоят в очереди за P».
  • Читается без STW, дёшево, годится для экспорта раз в секунду.
  • В prometheus/client_golang подключается через collectors.NewGoCollector(collectors.WithGoCollectorRuntimeMetrics(...)).

expvar

Пакет отдаёт JSON на /debug/vars и по умолчанию публикует memstats и cmdline. Удобно для быстрого дебага и своих счётчиков (expvar.NewInt, expvar.Publish), но он тянет ReadMemStats со всеми последствиями, регистрируется в http.DefaultServeMux (та же проблема безопасности, что у pprof) и публикует командную строку. В проде его закрывают так же, как pprof.

Что мониторить на самом деле

Минимальный набор для Go-сервиса: /gc/heap/live (утечки), /sched/goroutines (утечка горутин, ловится раньше памяти), /cpu/classes/gc/total (перегруз сборщика), /sched/pauses/total/gc p99, /memory/classes/total без отданного ОС против GOMEMLIMIT и /sched/latencies p99 (нехватка P, а с Go 1.26 её же подтверждает ненулевая /sched/goroutines/runnable). Отдельно заведи алерт на рост числа горутин: он срабатывает за часы до того, как память станет заметной проблемой.

3.4Оптимизации

Приёмы, про которые спрашивают «а как ускорял?». Порядок важнее самих приёмов: сначала измерение, потом алгоритм, потом аллокации, и только в самом конце микрооптимизации вроде порядка полей. Кто начинает с последнего, обычно тратит неделю ради 0.4%.

Преаллокация

append при нехватке ёмкости выделяет новый массив, копирует туда старый и делает старый мусором. Двадцать append-ов в пустой слайс обходятся в шесть аллокаций, пять мусорных массивов и 31 скопированный элемент вместо одной аллокации и нуля копий. Для элементов не больше 32 байт (скажем, int) с Go 1.26 первые из них ложатся в буфер на стеке, и аллокаций в куче меньше, но копии и мусор остаются.

Без преаллокации: var out []Row, затем 20 раз append 1 2 4 8 16 cap 32 — актуальный массив красные — уже мусор: 6 аллокаций, 5 массивов в мусор, 31 скопированный элемент С преаллокацией: out := make([]Row, 0, 20) cap 20 — один массив на всё 1 аллокация, 0 копирований, 0 мусора Правило роста (Go 1.18+): пока cap меньше 256 — удвоение; дальше cap += (cap + 3*256)/4, то есть коэффициент плавно падает с 2x до 1.25x. Затем размер округляется вверх до размерного класса аллокатора — поэтому реальный cap часто больше запрошенного. Мапа (Swiss Tables, Go 1.24+) растёт иначе: таблица удваивается при заполнении на 7/8, после 1024 слотов делится; delete память не возвращает.
Цена роста без cap. Каждое перевыделение создаёт новый объект, копирует данные и отправляет старый массив в мусор. Если длина известна, передай её сразу: make([]T, 0, n) убирает всю эту работу.
// плохо: 6 аллокаций и мусор
var out []Row
for _, r := range rows { out = append(out, transform(r)) }

// хорошо: одна аллокация
out := make([]Row, 0, len(rows))
for _, r := range rows { out = append(out, transform(r)) }

// Ловушка №1: make([]T, n) вместо make([]T, 0, n)
out := make([]Row, len(rows))       // уже лежат n нулевых элементов
for _, r := range rows { out = append(out, transform(r)) }
// на выходе 2n элементов, первые n нулевые. Классический баг.

// Ловушка №2: append на подслайс пишет в чужой массив
a := []int{1, 2, 3, 4, 5}
b := a[:2]
b = append(b, 99)                    // затёр a[2]: cap(b) == 5
c := a[:2:2]                         // full slice expression: cap=2, append скопирует
c = append(c, 99)                    // a не тронут

// Ловушка №3: слайс переживает срез
data, _ := os.ReadFile("big.bin")    // 10 МБ
head := data[:16]                    // весь массив жив, пока жив head
head = slices.Clone(data[:16])       // а так — только 16 байт

// мапы преаллоцируются так же
m := make(map[string]int, len(keys))
Про мапы: три вещи, которые часто не знают
  • delete не уменьшает число бакетов. Мапа, разросшаяся до миллиона записей, после полной очистки продолжает занимать память под таблицу. Вернуть её можно, только создав новую мапу (clear(m) из Go 1.21 тоже не уменьшает).
  • Адрес элемента мапы взять нельзя (&m[k] не скомпилируется): при росте мапы элементы эвакуируются, адреса менялись бы.
  • С Go 1.24 мапы внутри перешли на Swiss Tables: меньше памяти, быстрее lookup (особенно на промахах), плотнее раскладка. Внешнее поведение и порядок итерации не изменились.

Снижение аллокаций

// плохо
s := ""
for _, x := range parts {
    s += x            // новая строка каждую итерацию: O(n^2)
}

key := fmt.Sprintf("u:%d:%s", id, name)

func rows(n int) []Row {
    var out []Row
    for i := 0; i < n; i++ {
        out = append(out, Row{})
    }
    return out
}

// в цикле: новый буфер каждый раз
for _, r := range reqs {
    buf := make([]byte, 0, 4096)
    buf = encode(buf, r)
}
// хорошо
var sb strings.Builder
sb.Grow(totalLen)     // одна аллокация
for _, x := range parts {
    sb.WriteString(x)
}
s := sb.String()      // без копии: Builder отдаёт свой массив

key := make([]byte, 0, 32)
key = append(key, "u:"...)
key = strconv.AppendInt(key, id, 10)
key = append(key, ':'); key = append(key, name...)

func rows(n int) []Row {
    out := make([]Row, 0, n)
    ...
}

// один буфер на всё, сброс длины
buf := make([]byte, 0, 4096)
for _, r := range reqs {
    buf = buf[:0]
    buf = encode(buf, r)
}
  • strings.Builder вместо +=: конкатенация в цикле стоит O(n²) по копированию. Grow(n) добивает до одной аллокации. При этом Builder.String() не копирует данные (внутри unsafe), поэтому сам Builder копировать нельзя; go vet такую копию не ловит, её ловит паника при записи в копию.
  • strconv.Append* и fmt.Appendf (Go 1.19) вместо Sprintf: пишут в твой буфер, не создавая строку.
  • Переиспользование буфера через buf = buf[:0]: ёмкость сохраняется, аллокаций после прогрева ноль.
  • sync.Pool берут для крупных короткоживущих объектов, обязательно с Reset() и с порогом «слишком большой буфер обратно не кладём».
  • Компилятор уже умеет часть конверсий бесплатно: m[string(b)] не аллоцирует строку, string(b) == "x" тоже, и for i, c := range []byte(s) обходится без копии. Это стоит знать до того, как тянуться к unsafe.
Go 1.26: тот же приём внутри stdlib ускорил io.ReadAll вдвое

Описанное выше тут уже сделали за тебя. Старый io.ReadAll читал всё в один слайс, растущий через append: каждый раз, когда буфер кончался, выделялся новый, крупнее прежнего, и копировалось всё уже прочитанное. На ответе в 10 МБ набегали десятки перевыделений и десятки мегабайт лишнего копирования. Это не квадратичность, как у s += x в цикле, а та же цена роста без cap, что разобрана выше.

В Go 1.26 функцию переписали: прочитанное копится отдельными кусками (каждый следующий в полтора раза больше предыдущего), которые никто не перекопирует, а в самом конце всё один раз склеивается в слайс точного размера. По цифрам релиза вышло примерно вдвое быстрее и вдвое меньше выделенной памяти (речь о байтах, а не о числе аллокаций). Выводов два. Первый: самодельные обёртки, написанные когда-то только ради обхода медленного ReadAll, больше не нужны. Второй остался прежним. Если длина известна заранее (Content-Length, размер файла), io.ReadFull в преаллоцированный буфер всё равно дешевле: одна аллокация и ноль склеек. И ReadAll по-прежнему держит всё тело в памяти, так что большие ответы читай потоком (io.Copy, json.Decoder), а не ускоренным ReadAll.

// unsafe-конверсии без копирования (Go 1.20+)
func b2s(b []byte) string { return unsafe.String(unsafe.SliceData(b), len(b)) }
func s2b(s string) []byte { return unsafe.Slice(unsafe.StringData(s), len(s)) }
Когда unsafe-конверсии допустимы, а когда это бомба

Обычные []byte(s) и string(b) копируют данные, и за счёт этого строки в Go остаются неизменяемыми и безопасными. unsafe-версии делят одну память, и правила у них жёсткие:

  • b2s: полученную строку можно использовать только пока никто не пишет в исходный слайс. Поменяешь байт, и строка молча изменится, а если она успела стать ключом мапы, мапа сломана навсегда.
  • s2b: в полученный слайс нельзя писать вообще никогда. Строковые литералы лежат в read-only секции, и запись туда даст SIGSEGV (на macOS SIGBUS). Плюс интернированные компилятором строки общие на всю программу.
  • Нельзя отдавать результат наружу пакета и нельзя класть в долгоживущие структуры.

Допустимо: горячий путь внутри одного пакета, короткое время жизни, есть бенчмарк, доказывающий выигрыш, и тесты гоняются под -race, который включает и проверку -d=checkptr. Недопустимо: «мне показалось, что так быстрее». На собесе отвечай так: «знаю как, применял, но только за границей горячего цикла и с комментарием, почему это безопасно именно здесь».

Go 1.27 удешевил мелкие аллокации, но приёмы выше от этого не устарели

В Go 1.27 объекты меньше 80 байт выделяются примерно на 30 % дешевле: компилятор подставляет процедуру, специализированную под конкретный размерный класс, вместо универсальной mallocgc с вычислениями и ветвлениями в рантайме (механику разбирает глава 3.1). За это бинарь прибавляет примерно 60 КБ; выключить специализацию можно через GOEXPERIMENT=nosizespecializedmalloc.

Из этого не следует, что аллокации можно больше не считать: подешевела одна статья расходов из трёх. Аллокация в куче платит трижды: за выдачу места, за обход сборщиком на маркировке и за уборку на sweep. Ускорилась только первая часть, а две остальные зависят от того, сколько объектов ты создал, и никуда не делись. Ноль аллокаций по-прежнему лучше, чем дешёвая аллокация. Есть и практический подвох: цифры ns/op из бенчмарков, снятых на 1.26 и раньше, на 1.27 сдвинутся сами по себе. Сравнивай «до» и «после» только на одной версии тулчейна, иначе припишешь себе чужую оптимизацию.

Выравнивание структур и порядок полей

У каждого типа своё требование выравнивания. На 64-битных платформах int64, float64 и указатели выравниваются по 8 байт, int32 по 4, int16 по 2, byte/bool по 1. Компилятор не переставляет поля (порядок входит в определение типа) и добивает промежутки padding-ом. Значит, порядок полей напрямую определяет размер.

Одни и те же четыре поля, разный порядок объявления 0 8 16 24 24 байта padding 7 Б b int64 pad 3 d int32 a bool c bool 10 из 24 байт — пустота. На миллионе элементов это 10 МБ впустую и лишний трафик через кэш. 0 8 16 16 байт b int64 d int32 pad 2 a c Порядок «от большего выравнивания к меньшему» — и padding сжался с 10 байт до 2. Проверить: unsafe.Sizeof / unsafe.Alignof / unsafe.Offsetof, либо анализатор fieldalignment из golang.org/x/tools.
Порядок полей. Компилятор не переставляет поля сам, поэтому за раскладку отвечает автор типа. Правило простое: сортируй поля по убыванию размера выравнивания. Экономить стоит только на типах, которых в памяти миллионы.
type Bad struct {   // 24 байта
    A bool          // 0,   +7 padding
    B int64         // 8
    C bool          // 16,  +3 padding
    D int32         // 20
}
type Good struct {  // 16 байт
    B int64         // 0
    D int32         // 8
    A bool          // 12
    C bool          // 13,  +2 padding в конце
}

fmt.Println(unsafe.Sizeof(Bad{}), unsafe.Sizeof(Good{}))   // 24 16
fmt.Println(unsafe.Alignof(int64(0)), unsafe.Offsetof(Bad{}.B))  // 8 8
$ go install golang.org/x/tools/go/analysis/passes/fieldalignment/cmd/fieldalignment@latest
$ fieldalignment ./...
/app/model.go:12:12: Order has size 24 but the optimal size is 16 leading to a waste of 8 bytes (33%)
$ fieldalignment -fix ./...      # переставит поля сам
Второй смысл выравнивания: false sharing и atomic

False sharing: два счётчика разных горутин в одной кэш-линии (64 байта) заставляют ядра гонять линию туда-сюда, и производительность падает в разы, хотя счётчики друг от друга не зависят. Лечится паддингом до размера линии: _ [64]byte между полями. С atomic отдельная история: int64-поле, к которому применяют atomic.AddInt64, на 32-битных платформах надо вручную выравнивать по 8 байт, иначе будет паника; это требование действует и сейчас. Поэтому с Go 1.19 бери типы atomic.Int64/atomic.Pointer[T]: align-хак спрятан у них внутри, а копирование заодно ловит vet.

Инлайнинг

Инлайн подставляет тело функции в место вызова. Выигрыш не столько в убранном CALL (он дешёвый), сколько в том, что после подстановки включаются остальные оптимизации: escape analysis видит всю картину и может оставить объект на стеке, константы сворачиваются, проверки границ и мёртвые ветки выкидываются.

Бюджет

Компилятор считает «стоимость» тела в условных узлах AST и сравнивает с бюджетом 80 (inlineMaxBudget). Обычная операция стоит 1, а вызов другой функции обходится в 57 (inlineExtraCallCost). Значит, функция с двумя неинлайнящимися вызовами внутри почти наверняка сама в бюджет не влезет. С Go 1.12 работает mid-stack inlining, так что заинлайнить можно и функцию, которая сама кого-то вызывает.

Что мешает инлайну

  • Тело дороже 80 узлов.
  • Рекурсия ограничивает глубину: рекурсивную функцию подставят на один уровень, дальше цикл не разворачивается (до Go 1.25 прямая рекурсия запрещала инлайн совсем).
  • recover() в теле.
  • defer в теле. А select, метки, goto и циклы с range давно инлайнятся.
  • Вызов через интерфейс или переменную-функцию: цель неизвестна на этапе компиляции. Компилятор умеет девиртуализовать, когда конкретный тип очевиден, но в общем случае не может.
  • Явный запрет //go:noinline (его ставят в бенчмарках и тестах).
  • Функции, объявленные на ассемблере, и часть рантайм-функций.
$ go build -gcflags='-m -m' ./pkg 2>&1 | grep -i inlin
pkg/h.go:10:6: can inline small with cost 6 as: func(int) int { return x * 2 + 1 }
pkg/h.go:18:6: cannot inline big: function too complex: cost 90 exceeds budget 80
pkg/h.go:24:6: cannot inline cleanup: unhandled op DEFER
pkg/h.go:31:6: can inline fact with cost 71 as: func(int) int { if n <= 1 { return 1 }; return n * fact(n - 1) }
pkg/h.go:26:14: inlining call to small      # подставили здесь
pkg/h.go:35:17: inlining call to fact       # рекурсия: fact в себя, один уровень

# отключить инлайн целиком (полезно, чтобы увидеть «честные» решения escape analysis)
$ go build -gcflags='-m -l' ./pkg
Практика

Не переписывай код ради инлайна «на всякий случай»: это ухудшает читаемость ради процентов. Работает другой приём: раздели горячую функцию на маленькую обёртку и холодный хвост. Обёртка с быстрой проверкой влезает в бюджет и инлайнится, а редкий медленный путь уходит в отдельную //go:noinline-функцию. Ровно так устроены sync.Mutex.Lock (быстрый CAS + lockSlow) и sync.Once.Do (проверка флага + doSlow).

PGO — profile-guided optimization

Идея: скормить компилятору реальный CPU-профиль, чтобы он знал, какие пути горячие. Появилась как превью в Go 1.20, стабильной стала в Go 1.21.

# 1. снять профиль с прода под реальной нагрузкой
$ curl -so cpu.pprof 'http://localhost:6060/debug/pprof/profile?seconds=60'
# 2. положить рядом с main-пакетом под именем default.pgo
$ cp cpu.pprof ./cmd/api/default.pgo
# 3. всё. go build подхватывает его сам, начиная с Go 1.21
$ go build ./cmd/api
# либо явно:
$ go build -pgo=./cpu.pprof ./cmd/api
$ go build -pgo=off ./cmd/api      # выключить

Что компилятор делает с профилем

  • Агрессивнее инлайнит горячие вызовы, потому что бюджет для них поднимается с 80 до 2000.
  • Девиртуализация интерфейсных вызовов: если в профиле видно, что 95% вызовов io.Writer.Write уходят в *os.File, компилятор ставит прямой вызов с проверкой типа и запасным путём, а этот прямой вызов уже можно заинлайнить.
  • Выравнивание горячих циклов (с Go 1.23, только amd64 и 386): заголовки горячих циклов выравниваются в коде, это ещё 1–1,5%. Перестановки блоков и функций по профилю в Go пока нет.
Реальный выигрыш и как про это говорить

Команда Go называет 2–14% на наборе типичных программ (с Go 1.22; в Go 1.21 было 2–7%). Вдвое сервис от этого не ускорится, зато по всему парку ты бесплатно вернёшь себе несколько процентов CPU, а для крупной инсталляции это заметные деньги. Оговорки: профиль должен быть репрезентативным (снят с прода под нагрузкой, а не с ноутбука), устаревший профиль не ломает сборку, а просто перестаёт помогать, время компиляции немного растёт, и профиль надо положить в репозиторий и периодически обновлять, обычно отдельной джобой в CI.

Сначала измерь

Этот порядок стоит проговорить вслух, именно за него ставят плюс:

  1. Есть ли проблема? SLO, метрики, жалобы. Без цифры задачи нет.
  2. Где именно? Профиль, а не интуиция. Ускорять то, что занимает 0.5% профиля, бессмысленно по закону Амдала.
  3. Алгоритм и архитектура. Убрать N+1 запрос, добавить индекс, закэшировать, батчить, не делать работу вовсе. Здесь лежат разы, а не проценты.
  4. Аллокации. Преаллокация, переиспользование буферов, меньше указателей. Здесь лежат десятки процентов.
  5. Микрооптимизации. Порядок полей, инлайн, unsafe, PGO. Здесь лежат единицы процентов.
  6. Проверить в проде. Бенчмарк доказал, что функция стала быстрее; только профиль после выката доказывает, что стало быстрее приложение.
Компромисс производительность / читаемость

Каждая оптимизация создаёт долг: код усложняется, и следующий человек может его сломать, не понимая, зачем так написано. Поэтому: (1) оптимизируй только измеренные горячие места, а их обычно 1–2% кода; (2) оставляй рядом комментарий с цифрами и ссылкой на бенчмарк; (3) оставляй сам бенчмарк в репозитории, чтобы регрессию поймал CI; (4) если выигрыш меньше разброса измерений, откатывай, даже если «красиво». Для собеса: «по умолчанию пишем читаемо, а нечитаемость надо заслужить цифрами».

Вопросы

6
Суть: append при нехватке ёмкости выделяет новый массив и копирует туда старый. Знаешь итоговый размер — скажи его сразу: make([]T, 0, n) превращает O(log n) аллокаций и O(n) копирований в одну аллокацию и ноль копий. Ловушки — make([]T, n) вместо 0, n, общий массив у подслайсов и удержание большого массива маленьким срезом.

Как растёт слайс

Когда len == cap, рантайм зовёт growslice. Правило с Go 1.18: если требуемая длина больше удвоенной ёмкости, берём её саму; иначе при cap < 256 ёмкость удваивается, а начиная с 256 растёт по формуле newcap += (newcap + 3*256) / 4, то есть коэффициент плавно съезжает с 2x к 1.25x. До Go 1.18 порог стоял на 1024, и переход шёл ступенькой. Потом полученный размер в байтах округляется вверх до класса размеров аллокатора (roundupsize), поэтому реальный cap часто больше расчётного и цифры в cap() иногда кажутся «странными».

var s []int
prev := -1
for i := 0; i < 2000; i++ {
    s = append(s, i)
    if cap(s) != prev { fmt.Print(cap(s), " "); prev = cap(s) }
}
// Реальный вывод go1.27:
// 4 8 16 32 64 128 256 512 848 1280 1792 2560
//
// Дальше удвоение до 256, затем ~1.25x и округление до size class (см. выше).
// А вот начало неожиданное: последовательность стартует не с 1, а с 4. Это работа
// компилятора (Go 1.26+): слайс не убегает из функции, и первый массив он берёт
// на стеке, 32 байта, то есть 4 int. Если тот же append спрятать за
// //go:noinline-функцию, слайс убежит, и вернётся честное 1 2 4 8 16. Для []byte
// стартовая ёмкость по той же причине 32. Мораль: конкретные первые числа —
// деталь реализации, а не гарантия; гарантирована только амортизированная O(1)
// на элемент.

При этом старый массив становится мусором. Рост обходится в копирование, работу для сборщика и всплеск RSS: в момент перевыделения живы оба массива сразу.

Что даёт преаллокация в цифрах

func BenchmarkNoCap(b *testing.B) {
    b.ReportAllocs()
    for b.Loop() {                      // Go 1.24
        out := []int(nil)
        for i := 0; i < 10000; i++ { out = append(out, i) }
        _ = out
    }
}
func BenchmarkWithCap(b *testing.B) {
    b.ReportAllocs()
    for b.Loop() {
        out := make([]int, 0, 10000)
        for i := 0; i < 10000; i++ { out = append(out, i) }
        _ = out
    }
}
// go test -bench=. на go1.27 (Apple M4 Pro, arm64):
// BenchmarkNoCap-14      31342   36412 ns/op   357625 B/op   19 allocs/op
// BenchmarkWithCap-14    63304   20332 ns/op    81920 B/op    1 allocs/op

Устойчиво воспроизводится выигрыш по памяти: 357 КБ против 82 КБ (в 4,4 раза) и 19 аллокаций против одной. Лишнего набегает много: скопировано и выброшено около 260 КБ, втрое больше, чем нужно самому результату. А вот выигрыш по времени сильно зависит от машины: в замере выше это всего 1,8x, потому что десять тысяч итераций append сами по себе стоят дороже, чем перевыделения. На более медленной памяти разрыв заметно больше, но обещать «в разы быстрее» не стоит, лучше аргументируй аллокациями и нагрузкой на GC.

Ловушки

  • make([]T, n) вместо make([]T, 0, n) встречается чаще всех. Первый вариант создаёт n нулевых элементов, и последующий append дописывает после них: получаем 2n элементов, из которых первые n нулевые. Ловят на собесе именно на этом.
  • Подслайс делит массив с оригиналом. b := a[:2] имеет cap(b) == cap(a), и append(b, x) молча затрёт a[2]. Лечится full slice expression a[:2:2], который обрезает ёмкость и вынуждает append копировать.
  • Маленький срез держит большой массив. head := data[:16] от 10-мегабайтного файла удерживает все 10 МБ живыми: GC видит указатель на начало объекта, а не на «использованную часть». Спасает slices.Clone или явное копирование.
  • append возвращает новый слайс, и результат обязательно присваивать. Классический баг: слайс передают в функцию, которая делает append, а новое значение не возвращают. Иногда изменения видны (влезло в cap), иногда нет.
  • Преаллокация вслепую тоже вредна. make([]T, 0, 1e6) на всякий случай гарантированно аллоцирует в куче (объект больше 32 КБ идёт в large object span мимо кэшей) и раздувает RSS. Ёмкость должна быть обоснованной: len входных данных, LIMIT запроса, размер батча.
// вырастить заранее под известную добавку (Go 1.21)
s = slices.Grow(s, need)   // гарантирует место под need элементов, без лишних копий
s = slices.Clip(s)         // cap = len: append больше не пишет в общий хвост; память не освобождается
Мапы преаллоцируются, но не сжимаются

make(map[K]V, n) сразу создаёт достаточно бакетов и убирает серию перехеширований при заполнении. Работает это так же хорошо, как cap у слайса. А вот обратно память не возвращается: ни delete, ни clear(m) (Go 1.21) не уменьшают число бакетов. Мапа, разово выросшая до миллиона ключей, держит таблицу до конца жизни, так что кэш на мапе надо периодически пересоздавать. С Go 1.24 внутри Swiss Tables: раскладка плотнее и лукап быстрее, но правило «не сжимается» осталось.

Как это звучит на собесе

«Если знаю размер результата, всегда пишу make([]T, 0, n). Это одна строка, она убирает лог-число аллокаций, копирование и мусор. Если не знаю, число не выдумываю, а смотрю в -benchmem: если allocs/op растёт с размером входа, значит место для преаллокации есть.»

Суть: четыре уровня. Не аллоцировать вовсе (писать в чужой буфер), аллоцировать один раз (Grow, cap), переиспользовать (buf = buf[:0], sync.Pool) и не платить за конверсии, которые компилятор и так умеет делать бесплатно. unsafe — последний шаг, и только с бенчмарком.

strings.Builder

Конкатенация s += x в цикле копирует O(n²) байт: строки неизменяемы, и каждая итерация создаёт новую. Внутри strings.Builder лежит обычный []byte, который растёт по правилам append, а String() отдаёт его без копирования через unsafe.String. Поэтому Builder нельзя копировать после первой записи: он хранит указатель сам на себя (copyCheck) и при копии паникует «strings: illegal use of non-zero Builder copied by value». go vet такую копию не ловит: его проверка copylocks знает только типы с методами Lock/Unlock вроде мьютексов.

var b strings.Builder
b.Grow(len(parts) * 8)          // одна аллокация вместо серии удвоений
for i, p := range parts {
    if i > 0 { b.WriteByte(',') }
    b.WriteString(p)
}
return b.String()               // без копии

// нужен []byte и переиспользование: bytes.Buffer или свой слайс
// склеить фиксированный набор: strings.Join (одна аллокация)

Разница с bytes.Buffer: Buffer умеет читать, а его Reset сохраняет выделенную память, поэтому он ложится в пул; Builder только пишет, его Reset память выбрасывает, зато String() бесплатен. Под «собрать строку и отдать» бери Builder, под «собрать байты, отправить, повторить» подойдёт Buffer или свой []byte.

Переиспользование буферов

// один буфер на весь цикл: cap сохраняется, аллокаций после прогрева ноль
buf := make([]byte, 0, 4096)
for _, r := range reqs {
    buf = buf[:0]
    buf = appendJSON(buf, r)
    w.Write(buf)
}

// Append-семейство вместо Sprintf: пишем в свой буфер, строку не создаём
buf = strconv.AppendInt(buf, id, 10)
buf = fmt.Appendf(buf, " %s\n", name)   // Go 1.19
// и на приёме тоже: json.Decoder/Encoder на потоке вместо Marshal в []byte

sync.Pool

Пул работает как кэш временных объектов, приколоченный к P: у каждого процессора есть приватный слот (доступ без синхронизации вообще) и разделяемая очередь, из которой другие P могут воровать. При сборке мусора пул не очищается сразу: живой набор уезжает в victim cache и умирает только на следующем цикле (Go 1.13), поэтому одна случайная сборка не обнуляет прогрев.

var bufPool = sync.Pool{
    New: func() any { return new(bytes.Buffer) },   // всегда указатель, не значение
}

func handle(w http.ResponseWriter, r *http.Request) {
    b := bufPool.Get().(*bytes.Buffer)
    b.Reset()                       // обязательно: пришёл чужой мусор
    defer func() {
        if b.Cap() <= 64<<10 {      // порог: раздутый буфер обратно не кладём
            bufPool.Put(b)
        }
    }()
    render(b, r)
    w.Write(b.Bytes())
}
  • Только указатели. Put(buf) со значением-слайсом кладёт его в any, а упаковка сама по себе аллоцирует, и staticcheck ругается на это кодом SA6002. Кладём *bytes.Buffer или *[]byte.
  • Reset обязателен, иначе данные утекают между запросами (в худшем случае в тело ответа попадёт чужой ответ, а это уже уязвимость).
  • Порог на размер. Без него один запрос на 200 МБ оставит в пуле 200-мегабайтный буфер, и пока пул в работе, этот буфер ходит по кругу и держит память. Ровно этот приём есть в fmt и net/http.
  • Пул не нужен для мелочи. Маленький объект из mcache выделяется за несколько наносекунд; Get/Put плюс промахи кэша могут стоить дороже. Пул окупается на крупных (килобайты), короткоживущих и часто создаваемых объектах.
  • После Put объект использовать нельзя: его уже могла забрать другая горутина. Типичная ошибка: defer pool.Put(b) и возврат b.Bytes() наружу.

Конверсии string и []byte

Обычные []byte(s) и string(b) копируют, и это цена неизменяемости строк. Но компилятор знает несколько случаев, где копия не нужна, и убирает её сам. Знать этот список важнее, чем уметь писать unsafe:

m[string(b)]                       // лукап по []byte без аллокации строки
if string(b) == "ping" { }         // сравнение без аллокации
switch string(b) { case "get": }   // тоже
for i, c := range []byte(s) { }    // обход байтов без копии; range string(b) копирует
w.Write([]byte(s))                 // здесь копия будет; io.WriteString(w, s) - нет, если у w есть WriteString
// ручные конверсии без копии (Go 1.20+), только для горячего пути
func b2s(b []byte) string { return unsafe.String(unsafe.SliceData(b), len(b)) }
func s2b(s string) []byte { return unsafe.Slice(unsafe.StringData(s), len(s)) }
Правила для unsafe-конверсий

b2s: строка валидна, только пока никто не пишет в исходный слайс; если такая строка попала ключом в мапу и байт изменился, мапа сломана навсегда. s2b: в результат нельзя писать никогда, потому что литералы лежат в read-only секции и запись даст SIGSEGV (на macOS SIGBUS). Ни то, ни другое нельзя отдавать наружу пакета или класть в долгоживущие структуры. Допустимо: короткий горячий путь внутри пакета, есть бенчмарк с выигрышем, тесты гоняются под -race (он включает и -d=checkptr), рядом комментарий с обоснованием.

Чем добить: аллокация нагружает ещё и GC

Уменьшая аллокации, ты бьёшь сразу по трём статьям: время на сам mallocgc, частота сборок (темп аллокации напрямую задаёт, как быстро куча дорастёт до цели GOGC) и стоимость маркировки. Причём последнее зависит не от числа объектов, а от числа указателей: объект без указателей попадает в noscan-класс, и сборщик его вообще не сканирует. Отсюда неочевидный приём: []Item вместо []*Item и индексы int32 вместо указателей дают одну аллокацию вместо n и ноль работы маркировщику.

Суть: у каждого типа есть требование выравнивания, а компилятор не переставляет поля — порядок объявления является частью типа. Значит, промежутки он добивает padding-ом, и размер структуры зависит от порядка. Правило: сортируй поля по убыванию выравнивания. Проверяет unsafe.Sizeof и анализатор fieldalignment.

Откуда берётся padding

На amd64 и arm64 выравнивание 8 у указателей, int, int64, float64; 4 у int32/float32/rune; 2 у int16; 1 у byte, bool, int8. Смещение поля обязано быть кратно его выравниванию, а размер всей структуры кратен максимальному выравниванию среди полей (чтобы в массиве второй элемент тоже лёг ровно). Отсюда и «хвостовой» padding в конце.

type Bad struct {   // 24 байта
    A bool          // offset 0,  +7 padding
    B int64         // offset 8
    C bool          // offset 16, +3 padding
    D int32         // offset 20
}
type Good struct {  // 16 байт
    B int64         // 0
    D int32         // 8
    A bool          // 12
    C bool          // 13, +2 хвостового padding
}

fmt.Println(unsafe.Sizeof(Bad{}), unsafe.Sizeof(Good{}))   // 24 16
fmt.Println(unsafe.Offsetof(Bad{}.D), unsafe.Alignof(Bad{}))     // 20 8
$ go install golang.org/x/tools/go/analysis/passes/fieldalignment/cmd/fieldalignment@latest
$ fieldalignment ./...
/app/model.go:12:12: Order has size 24 but the optimal size is 16 leading to a waste of 8 bytes (33%)
$ fieldalignment -fix ./...       # переставит поля сам

$ go build -gcflags=-m=2 ./...    # подробно: почему убегает и почему не инлайнится

Когда это вообще имеет значение

Сэкономленные 8 байт на структуре, которых в программе три штуки, ничего не решают. Смысл появляется, когда таких значений миллионы: срез из миллиона Bad занимает 24 МБ против 16 МБ, то есть 8 МБ чистой пустоты и в полтора раза больше памяти, которую обход тянет через кэш. Второй случай: структура летит по сети или в очередь миллионами раз в секунду. В обычном бизнес-коде это микрооптимизация последнего уровня, и ломать логическую группировку полей ради 8 байт на десяти объектах не стоит: размен плохой.

Ловушки

  • Пустая структура в конце. Поле типа struct{} последним в структуре получает padding до 1 байта: иначе указатель на него смотрел бы за границу объекта, а такие указатели рантайм не допускает. Если struct{} стоит не последним, размер не меняется.
  • Размер не равен «сумме полей». У string 16 байт (указатель + длина), у []T 24, у interface{} и любого интерфейса 16, у map/chan/func 8. Их содержимое лежит отдельно в куче, unsafe.Sizeof его не считает.
  • Не путать выравнивание с числом указателей. Иногда важнее не размер, а то, что структура вообще без указателей попадает в noscan-класс и не сканируется сборщиком.
Второй смысл: false sharing и atomic

False sharing ставит обратную задачу: тут padding не убирают, а добавляют. Два счётчика разных горутин в одной кэш-линии (64 байта) заставляют ядра гонять линию туда-сюда протоколом когерентности; переменные независимы, а throughput падает в разы. Помогает вставка _ [64]byte между полями, как в runtime и в шардированных счётчиках. Отдельная деталь: int64-поле, к которому применяют atomic.AddInt64, на 32-битных платформах нужно вручную выравнивать по 8 байт (первым полем структуры), иначе будет паника, и это правило действует до сих пор. С Go 1.19 вместо этого берут atomic.Int64 и atomic.Pointer[T]: выравнивание в них гарантировано, а копию ловит vet.

Как отвечать

«Компилятор поля не переставляет, поэтому за раскладку отвечаю я как автор типа. Сортирую по убыванию выравнивания, проверяю fieldalignment в CI. Но делаю это только для типов, которых в памяти миллионы. В остальных случаях читаемая группировка полей дороже восьми байт.»

Суть: компилятор подставляет тело функции в место вызова, если его «стоимость» влезает в бюджет 80 узлов (вызов внутри стоит целых 57). Выигрыш не столько в убранном CALL, сколько в том, что после подстановки включаются остальные оптимизации — в первую очередь escape analysis. Мешают: превышение бюджета, defer, go, recover, вызовы через интерфейс и //go:noinline.

Зачем это нужно

Сам вызов на современном процессоре стоит единицы наносекунд, и ради этого никто бы не старался. Инлайн ценен другим. Тело оказывается в контексте вызывающего, и дальше работают остальные проходы: escape analysis видит, что указатель никуда не утёк, и оставляет объект на стеке; константы сворачиваются; проверки границ (bounds check) удаляются, потому что индекс известен; мёртвые ветки выкидываются. Отсюда и типичная картина: убираешь //go:noinline, и заодно исчезает аллокация, которой в этой функции вроде бы неоткуда взяться.

Бюджет

Стоимость считают по узлам AST: обычная операция стоит 1, вызов другой функции целых 57 (inlineExtraCallCost), а лимит равен 80 (inlineMaxBudget). Выходит, функция с двумя неинлайнящимися вызовами внутри почти наверняка сама в бюджет не влезет. При этом с Go 1.12 работает mid-stack inlining: функцию, которая сама кого-то вызывает, всё равно заинлайнят, если её собственная стоимость (вместе с уже подставленными вызовами) укладывается в бюджет.

Что мешает

  • Тело дороже 80 узлов, в выводе function too complex: cost N exceeds budget 80.
  • Рекурсия ограничивает глубину: рекурсивную функцию подставят на один уровень, дальше цикл не разворачивается (до Go 1.25 прямая рекурсия запрещала инлайн совсем).
  • recover() в теле.
  • defer и go в теле (unhandled op DEFER).
  • Вызов через интерфейс или через переменную-функцию: цель неизвестна на этапе компиляции, инлайнить нечего. Компилятор умеет девиртуализовать, когда конкретный тип очевиден локально, но в общем случае не справляется.
  • Явный запрет //go:noinline: его ставят в бенчмарках, чтобы компилятор не выкинул измеряемый код целиком.
  • Функции на ассемблере и часть рантайм-функций.
Список ограничений меняется от версии к версии

Часть запретов со временем сняли: раньше не инлайнились функции с select, с range-циклом, с замыканиями внутри, с прямой рекурсией, а сейчас это уже работает. Свежий пример из той же серии: в Go 1.24–1.25 тело for b.Loop() в бенчмарках побочно теряло инлайн, а с Go 1.26 перестало. Поэтому верь не заученному списку, а выводу -gcflags=-m на конкретной версии Go. Так и отвечай: назови принцип (бюджет + невозможность построить эквивалентный код в теле вызывающего) и покажи, что умеешь проверить.

$ go build -gcflags='-m -m' ./pkg 2>&1 | grep -i inlin
pkg/h.go:10:6: can inline small with cost 6 as: func(int) int { return x * 2 + 1 }
pkg/h.go:18:6: cannot inline big: function too complex: cost 90 exceeds budget 80
pkg/h.go:24:6: cannot inline cleanup: unhandled op DEFER
pkg/h.go:31:6: can inline fact with cost 71 as: func(int) int { if n <= 1 { return 1 }; return n * fact(n - 1) }
pkg/h.go:26:14: inlining call to small
pkg/h.go:35:17: inlining call to fact

$ go build -gcflags='-m -l' ./pkg     # -l выключает инлайн: видно "честные" решения escape analysis
$ go build -gcflags='-l -l -l -l'     # наоборот, агрессивнее (для экспериментов)

Приём: быстрая обёртка плюс холодный хвост

// обёртка укладывается в бюджет и инлайнится в вызывающего
func (c *Cache) Get(k string) (V, bool) {
    if v, ok := c.fast[k]; ok { return v, true }   // горячий путь: пара операций
    return c.getSlow(k)                            // редкий путь вынесен
}

//go:noinline
func (c *Cache) getSlow(k string) (V, bool) {
    // блокировки, промах кэша, поход в БД - сколько угодно кода,
    // он не мешает инлайну Get, потому что живёт в другой функции
}

Ровно так устроены стандартные sync.Mutex.Lock (быстрый CAS плюс lockSlow) и sync.Once.Do (проверка флага плюс doSlow). Это единственная перестройка кода ради инлайна, которую стоит делать сознательно: она заодно и читается лучше, потому что быстрый путь видно сразу.

Не оптимизируй под инлайн вслепую

За инлайн платят: код вызывающего распухает, растёт размер бинарника и фрейма стека, хуже работает кэш инструкций. Компилятор этот баланс уже держит. Резать читаемую функцию на куски, «чтобы влезла в 80», ради процента почти всегда невыгодно. Вмешиваться стоит, когда функция в топе профиля, -m говорит cannot inline и бенчмарк подтверждает выигрыш от разделения. И для добивки: с PGO бюджет для горячих вызовов поднимается с 80 до 2000, так что часть «слишком сложных» функций начинает инлайниться сама, без переписывания.

Суть: отдаём компилятору настоящий CPU-профиль с прода, и он знает, какие пути горячие. Кладём default.pgo рядом с main-пакетом — и всё, go build подхватывает сам (превью в Go 1.20, стабильно с Go 1.21). Даёт агрессивный инлайн горячих вызовов, девиртуализацию интерфейсных вызовов и (на amd64) выравнивание горячих циклов; официальные цифры — 2–14%.

Как включить

# 1. снять профиль с прода под реальной нагрузкой
$ curl -so cpu.pprof 'http://localhost:6060/debug/pprof/profile?seconds=60'

# 2. положить рядом с main-пакетом под именем default.pgo и закоммитить
$ cp cpu.pprof ./cmd/api/default.pgo

# 3. всё; -pgo=auto включён по умолчанию с Go 1.21
$ go build ./cmd/api
$ go build -pgo=./cpu.pprof ./cmd/api    # явный путь
$ go build -pgo=off ./cmd/api            # выключить (например, для сравнения в бенчмарке)

# профили с нескольких подов лучше слить в один
$ go tool pprof -proto pod1.pprof pod2.pprof pod3.pprof > default.pgo

Что компилятор делает с профилем

  • Инлайн горячих вызовов. Для call site, отмеченных в профиле как горячие, бюджет поднимается до 2000 против обычных 80, и «слишком сложные» функции в горячем месте всё-таки подставляются.
  • Девиртуализация. Если в профиле видно, что 95% вызовов io.Writer.Write уходят в *os.File, компилятор ставит прямой вызов с проверкой типа и запасной веткой на общий случай. А прямой вызов дальше можно ещё и заинлайнить, так что эффекты складываются.
  • Выравнивание горячих циклов. С Go 1.23 на amd64 и 386 заголовки горячих циклов выравниваются в коде, по замерам команды Go это ещё 1–1,5%. Переставлять блоки и функции по профилю, как BOLT, компилятор Go пока не умеет.

Оговорки, за которые ставят плюс

  • Профиль должен быть репрезентативным. Снятый с ноутбука на синтетическом тесте профиль оптимизирует не тот код. Нужен прод (или staging с боевым трафиком) под нагрузкой, желательно усреднённый по нескольким инстансам и снятый не в момент деплоя.
  • Устаревший профиль не ломает сборку. Если функции переименовали и код переписали, компилятор просто не найдёт совпадений и соберёт как обычно. Хуже не станет, пропадёт только выигрыш. Обычно профиль обновляют отдельной джобой в CI, например еженедельно.
  • Итеративная стабильность. Профиль, снятый с PGO-сборки, годится для следующей PGO-сборки: команда Go специально проверяет, что бинарь не «расходится» от поколения к поколению.
  • Платишь временем компиляции. С Go 1.23 это единицы процентов (раньше крупные сборки замедлялись вдвое и больше), немного растёт и размер бинарника. Кэш сборки при смене профиля инвалидируется целиком.
Как честно оценить эффект

По официальным измерениям Go (с Go 1.22), PGO ускоряет набор типичных программ на 2–14%; в Go 1.21 было 2–7%. Ускорения вдвое не жди. Речь о нескольких бесплатных процентах CPU по всему парку, которые для крупной инсталляции превращаются в реальные деньги и в отложенное масштабирование. На собесе формулируй так: «PGO добирает последний процент, когда алгоритмы и аллокации уже вычищены. Включается одним файлом в репозитории, код менять не надо, поэтому соотношение усилий и выигрыша отличное. Но ставить его первым пунктом в плане оптимизации нельзя».

Не путать с двумя соседями

PGO живёт в компиляторе: флаг -pgo, файл default.pgo. BOLT/линкерные оптимизации работают уже с готовым бинарником и к Go напрямую не относятся. Отдельно стоит GC-тюнинг: GOGC и GOMEMLIMIT меняют поведение рантайма во время работы, а не сгенерированный код. На собесе это разные ответы, и путать их не стоит.

Суть: оптимизация начинается не с кода, а с цифры: есть ли проблема (SLO, метрики) и где именно она (профиль). Дальше — по убыванию отдачи: алгоритм и архитектура, потом аллокации, и только в конце микрооптимизации. Читаемость — дефолт; право написать непонятно надо заслужить измерением, комментарием и бенчмарком в репозитории.

Порядок действий

  1. Есть ли проблема? Нарушенный SLO, растущая стоимость железа, жалоба. Без цифры задачи нет. «Кажется, тут медленно» ещё не задача.
  2. Где именно? Профиль, а не интуиция. По закону Амдала функция, занимающая 2% времени, даже ускоренная вдвое, даст 1%. Это не окупает ни дня работы, ни усложнения кода.
  3. Алгоритм и архитектура. Убрать N+1 запрос, добавить индекс, закэшировать, сбатчить, не делать работу вовсе. Здесь лежат разы. Быстрее всего работает код, который не выполняется.
  4. Аллокации. Преаллокация, переиспользование буферов, меньше указателей, меньше лишних копий. Здесь лежат десятки процентов, и заодно снижается нагрузка на GC.
  5. Микрооптимизации. Порядок полей, инлайн, PGO, unsafe. Здесь лежат единицы процентов.
  6. Проверить в проде. Бенчмарк доказывает, что функция стала быстрее; что стало быстрее приложение, доказывает только метрика после выката.

Измерять — значит измерять статистически

$ go test -run=^$ -bench=Encode -benchmem -count=10 > old.txt
# ... правка ...
$ go test -run=^$ -bench=Encode -benchmem -count=10 > new.txt
$ benchstat old.txt new.txt
          │   old.txt    │               new.txt               │
          │    sec/op    │   sec/op     vs base                │
Encode-14   128.65n ± 3%   36.98n ± 1%  -71.25% (p=0.000 n=10)

          │  old.txt   │              new.txt               │
          │    B/op    │   B/op     vs base                 │
Encode-14   80.00 ± 0%   0.00 ± 0%  -100.00% (p=0.000 n=10)

          │  old.txt   │               new.txt               │
          │ allocs/op  │ allocs/op   vs base                 │
Encode-14   5.000 ± 0%   0.000 ± 0%  -100.00% (p=0.000 n=10)

Один прогон «до» и один «после» ничего не доказывают: разброс на ноутбуке легко даёт 10–15%. Нужны -count и benchstat с p-value. Если p > 0.05 или дельта меньше разброса, изменения нет и правку откатываем, даже если она «явно быстрее по логике».

Компромисс с читаемостью

  • Оптимизируй только измеренное. Горячего кода в сервисе обычно 1–2%; остальные 98% должны быть максимально скучными и понятными.
  • Локализуй сложность. Хитрость прячется за обычным интерфейсом: снаружи Encode(w, v), а внутри пул, буфер и unsafe. Тогда её читает один человек, а не весь отдел.
  • Комментарий с цифрами обязателен. Не «так быстрее», а «был Sprintf: 129 нс, 5 аллокаций; стало 37 нс, 0 аллокаций; см. BenchmarkEncode». Без этого следующий человек «упростит» код обратно и будет прав, потому что обоснования нет.
  • Бенчмарк остаётся в репозитории. Он защищает от регрессии: без него оптимизация протухнет за пару рефакторингов.
  • Ограничивай радиус. unsafe и ручные трюки держи внутри одного пакета, не в API и не в доменных типах.
Цитата, которую обычно обрезают

Кнута цитируют как «преждевременная оптимизация — корень всех зол», а полностью фраза звучит так: «мы должны забыть про малую эффективность примерно в 97% случаев; преждевременная оптимизация — корень всех зол. Но нельзя упускать возможности в оставшихся 3%». То есть речь не про «оптимизировать не надо», а про порядок: сначала найди те 3%, потом работай. На собесе от тебя ждут не отказа от оптимизации, а дисциплины измерения.

Ответы, которые портят впечатление
  • «Я везде использую sync.Pool и unsafe, так быстрее». Без профиля это заявка на баги, а не на производительность.
  • «Заменил append на копирование вручную, стало на 3% быстрее», а сервис при этом 90% времени ждёт БД. Классическая оптимизация не того места.
  • «Померил на ноутбуке с открытым браузером, разница 5%». На таком стенде это шум.
  • «Оптимизировал, но метрики после выката не смотрел», то есть работу не довёл до конца.
Формулировка на одну фразу

«По умолчанию пишу читаемо, а нечитаемость надо заслужить цифрами. Порядок такой: метрика показала проблему, профиль показал место, бенчмарк зафиксировал базу, правка дала статистически значимый выигрыш, комментарий объяснил зачем, метрика в проде подтвердила. Если хоть один шаг не сошёлся, откатываю.»