Память, 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.
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: параметр уходит в первый результат |
-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не отменяет побег: объект всё равно в куче, просто он переиспользуется и не создаёт нового мусора.
Возврат структуры: по значению или по указателю
Здесь «зависит» и есть точный ответ, потому что в разные стороны тянут три силы.
- Копирование. Возврат по значению копирует структуру. Но с Go 1.17 на amd64 (с Go 1.18 и на arm64) работает register-based ABI: на amd64 до девяти целочисленных слов и пятнадцати float-регистров (на arm64 по шестнадцать) передаются в регистрах, без записи в память вообще. Структура на 2–4 поля возвращается практически бесплатно.
- Аллокация. Возврат указателя почти всегда означает
escapes to heap:mallocgcплюс будущая работа сборщика. Счёт идёт на десятки наносекунд плюс амортизированная стоимость GC. - Работа GC потом.
[]Tиз структур без указателей сборщик не сканирует вообще (спан помечен как noscan).[]*Tна миллион элементов означает миллион объектов, которые надо обходить каждый цикл.
Маленькую структуру (примерно до 4–6 слов, то есть 32–48 байт), которую не надо мутировать, возвращай по значению. Большую, мутируемую или обязанную быть общей возвращай по указателю. Если у типа уже есть методы с указательным receiver, возвращай указатель, иначе получишь несогласованный API. Обычно же разница тонет в работе самой функции: в горячих путях измеряй, в остальных выбирай по читаемости.
Стек горутины: как он растёт
Горутина стартует с маленьким стеком: stackMin = 2 КБ. С Go 1.19 рантайм
умнее: при сканировании стеков в GC он считает, сколько стека в среднем занято у всех
горутин, и новым сразу выделяет столько, чтобы не платить за первые расширения.
Пролог почти каждой функции (кроме крошечных листовых и помеченных
//go:nosplit) сравнивает SP с полем stackguard0 в структуре
g. Если места не хватает, вызывается runtime.morestack, а тот зовёт
newstack:
- выделяется новый стек как минимум вдвое больше старого (удваивается, пока не влезет новый кадр);
- содержимое копируется одним
memmove; - рантайм проходит по кадрам и чинит все указатели, которые указывали внутрь старого стека, а где они лежат, узнаёт по картам указателей (stack maps), которые компилятор сгенерировал для каждой точки вызова;
- старый стек возвращается в пул.
Обратную операцию, shrinkstack, рантайм выполняет во время GC: если горутина
использует меньше четверти своего стека, он ужимается вдвое. Потолок задаёт
maxstacksize: 1 ГБ на 64-битных платформах (250 МБ на 32-битных), настраивается
через debug.SetMaxStack. Превышение даёт fatal error: stack overflow,
и это не паника: recover её не поймает, процесс умирает.
memmove
и правит указатели по stack maps. Цена амортизируется удвоением, поэтому «дорогих» переездов
за жизнь горутины единицы.Кроме оптимизации, у escape analysis есть вторая роль: на нём держится корректность перемещаемых стеков. Рантайм при переезде чинит указатели только внутри стеков и регистров. Если бы указатель на стековую переменную мог оказаться в куче, чинить его было бы негде, и после переезда он смотрел бы в освобождённую память. Поэтому всё, до чего можно добраться из кучи, обязано быть в куче: анализ не «хотел бы», а должен вытолкнуть такую переменную. Скажешь это на собесе, и сразу станет видно, что ты понимаешь механизм, а не выучил список правил.
Вопросы
6Стек
У каждой горутины свой стек (стартует с 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{}не гарантируют кучу, а литерал структуры не гарантирует стек.
Почему «дешевле» — по пунктам
- Выделение: одна арифметическая операция против
mallocgcс поиском размерного класса, проверкой кэша и (иногда) с уходом вmcentral. С Go 1.27 этот разрыв немного сократился: для объектов до 80 байт компилятор подставляет процедуру, специализированную под конкретный размерный класс, и мелкая аллокация дешевеет до 30 % (ценой ~60 КБ размера бинаря). - Освобождение: бесплатно и мгновенно, против отложенной работы sweeper-а.
- Работа GC: стек сканируется один раз за цикл целиком, объекты в куче обходятся по графу.
- Локальность: кадры лежат подряд, объекты в куче разбросаны, отсюда больше промахов кэша.
«Разница не столько в скорости самой аллокации: mallocgc из mcache тоже быстрый,
десятки наносекунд. Разница в том, что объект в куче создаёт работу в будущем: его надо
промаркировать в следующем цикле GC и подмести. Стековая переменная для сборщика вообще
ничего не создаёт. Поэтому в горячем пути смотрят не на время аллокации, а на allocs/op.»
Формально компилятор строит граф «кто на кого может ссылаться»: вершины — переменные и аллокации,
рёбра — присваивания с весом derefs (число разыменований минус взятий адреса). Затем ищет
пути, по которым адрес попадает в кучу или переживает свою переменную. Для каждой функции получается
сводка по параметрам, которая уезжает в export data пакета, поэтому анализ работает и через
границы пакетов. Отсюда строки вида leaking param: name в выводе -m.
Правила побега (в порядке частоты)
- Возврат указателя на локальную переменную, например
return &User{}. Ссылка по определению живёт дольше кадра. - Запись указателя в объект, который сам в куче:
global = &x,s = append(s, &x),obj.F = &x. Рантайм держит инвариант: из кучи нельзя ссылаться на стек, иначе перемещение стека всё сломает. - Упаковка в интерфейс:
fmt.Println(x),var a any = x. Интерфейс хранит указатель на данные, а метод вызывается непрозрачно, значит, компилятор обязан считать, что данные утекают. Исключение бывает, когда компилятор девиртуализует вызов и видит тело. - Захват замыканием, которое переживает функцию, как в
go func(){ use(x) }()илиreturn func(){...}. Объект-замыкание уезжает в кучу; мелкие неизменяемые переменные копируются в него, а те, что замыкание меняет, переезжают в кучу отдельно. - Отправка в канал (
ch <- &x): читатель сидит на другом стеке. - Неизвестный на компиляции размер:
make([]byte, n)с переменнойn,make(map[K]V, n). В кадре нельзя зарезервировать «сколько-нибудь», поэтому на стеке есть только запас на мелкий случай: 32 байта у слайса (с Go 1.25), одна группа на 8 элементов у мапы. - Слишком большой объект. У компилятора есть лимиты: 128 КБ (до Go 1.24 было 10 МБ) для явных локальных
переменных и 64 КБ для неявных аллокаций (
new,&T{}, литералы,makeс константой). - Непрозрачный вызов через переменную-функцию или интерфейс: тела не видно, поэтому считаем, что параметры утекают. Рекурсия к этому не относится: рекурсивные функции компилятор анализирует вместе и видит их тела, так что параметры вполне могут не утекать.
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 и сразу оптимизировать значит оптимизировать вслепую.
Приёмы, которые реально работают
- Отдавать буфер снаружи.
func F(dst []byte, ...) []byteвместоfunc F(...) []byte. Так устроеныstrconv.AppendInt,time.Time.AppendFormat,fmt.Appendf(Go 1.19). - Принимать указатель на результат вместо возврата указателя:
func Parse(b []byte, out *Msg) error. - Не пропускать значение через
anyв горячем пути:fmt.Sprintfбоксит каждый аргумент. Вместо него бериstrconv,strings.Builder, дженерики (для типов с одинаковой GC-формой инстанцирование не создаёт интерфейс). - Ограничить область жизни: не сохранять указатель в глобальную структуру «на всякий случай», не передавать в горутину то, что можно передать копией.
- Константные размеры:
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 выбрала третий
путь: улучшать сам анализ. За последние релизы он научился, например, оставлять на стеке
мелкие слайсы, размер которых известен только в рантайме, и часть случаев с девиртуализацией интерфейсов.
Три силы
- Стоимость копии. С Go 1.17 на amd64 (с Go 1.18 и на arm64) работает register-based ABI: на amd64 9 целочисленных регистров и 15 float-регистров под аргументы и результаты, на arm64 по 16. Структура из 2–4 машинных слов возвращается вообще без записи в память.
- Стоимость аллокации. Возврат
*Tиз функции почти всегда заканчивается строкойescapes to heap, а за ней стоятmallocgcи будущая работа сборщика. - Стоимость для 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% функций разница
тонет в том, что функция делает по существу.
Механика
- Пролог почти каждой функции сравнивает
SPс полемstackguard0в структуреg. Проверка занимает пару инструкций, ветка предсказуемая. - Места не хватило: вызывается
runtime.morestack, горутина попадает вnewstack. - Выделяется новый блок как минимум вдвое большего размера (стеки берутся из пулов по размерам:
2, 4, 8, 16 КБ и дальше из
mheap). memmoveкопирует старые кадры в новый блок.- Рантайм проходит по кадрам, по stack maps находит все слоты-указатели и прибавляет
дельту к тем, что указывали внутрь старого стека. Плюс правит указатели в
defer-записях и вg. - Старый блок освобождается, выполнение продолжается с того же места.
Сжатие тоже есть: если при сканировании стека в фазе маркировки занято меньше четверти,
рантайм может скопировать стек в блок вдвое меньше (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 выполняется, но обслуживается не сборщиком, а компилятором: короткоживущие значения escape analysis оставляет на стеке, и они умирают бесплатно, вообще не попадая в кучу. Вдобавок поколенческий GC почти всегда перемещающий, а перемещение ломает совместимость с cgo и стабильность адресов. К 2018 году команда попробовала non-moving generational GC и отказалась: выигрыш не окупал цены барьера, который пришлось бы держать включённым всегда. Так глубоко на собесе обычно не копают.
Трёхцветная маркировка: зачем нужны именно три цвета
Возьмём бытовую картинку, её хватит надолго. Пусть куча будет большим зданием, объекты комнатами, а указатели дверями между комнатами. Входов с улицы несколько, и это корни: глобальные переменные и стеки живых горутин. Обходчик начинает от входов и метит комнаты мелом:
- Белая комната — до неё ещё не дошли.
- Серая — в неё зашли и пометили, но ещё не проверили, куда ведут двери из неё.
- Чёрная — зашли и все двери из неё уже проверили; возвращаться сюда незачем.
Обход кончается, когда серых комнат не осталось: значит, всё, до чего есть путь от входов, уже почернело. В оставшиеся белые комнаты с улицы не попасть. Их можно сносить.
Почему три, а не два («посетили / не посетили»). Потому что обход надо уметь прерывать и продолжать. Серые объекты и составляют список незавершённых дел: сборщик работает порциями, между порциями крутится приложение, и без отдельного цвета «нашли, но не дочитали» после перерыва пришлось бы начинать сначала. А главное, правило корректности, ради которого всё затевалось, формулируется через пару «чёрный–белый».
В двух местах. Первое: обходчик по зданию не ходит, а просто читает карту указателей (компилятор для каждого типа заранее знает, в каких байтах лежат указатели) и перескакивает по адресам. Второе, и куда важнее: в настоящей переписи жильцы сидят смирно, а здесь они всё время переносят двери, программа-то работает параллельно. Из-за одного этого отличия и понадобился барьер записи.
Физически цвет не поле в объекте, отдельного байта под него
нет. Есть биты маркировки в метаданных спана (один бит на объект) и очередь работы
(work queue, распределённая по P в виде gcWork с локальными буферами).
Цвет складывается из двух признаков:
- Белый — бит маркировки не стоит, объекта нет в очереди. Кандидат в мусор.
- Серый — бит стоит, но объект лежит в очереди: его нашли, а поля ещё не просмотрели.
- Чёрный — бит стоит, из очереди объект уже вынули и просканировали его поля.
Алгоритм: покрасить корни (стеки всех горутин, глобальные переменные, регистры) в серый; пока очередь не пуста, доставать объект, красить его в чёрный, а все его белые «дети» красить в серый и класть в очередь. Когда очередь опустела, всё белое считается мусором.
Сильный трёхцветный инвариант: ни один чёрный объект не должен ссылаться на белый. У него есть ослабленная версия, слабый инвариант: чёрный может ссылаться на белый, но только если до этого белого остаётся путь от какого-нибудь серого. Эти два правила и охраняют барьеры записи, а за точной формулировкой обычно охотится интервьюер.
Откуда такое правило, видно по аналогии со зданием: из чёрной комнаты обходчик уже ушёл и больше не вернётся. Если из неё внезапно появилась дверь в белую комнату, эту белую никто уже не пометит. Она так и останется белой и пойдёт под снос вместе с ведущей в неё дверью. Слабый инвариант такую дверь терпит, потому что оставляет запасной вход: пока до белой комнаты можно дойти от какой-нибудь серой (то есть от той, чьи двери ещё будут проверены), она будет найдена.
Объект теряется, когда одновременно случились две вещи: (1) ссылку на белый объект записали в уже чёрный и (2) удалили ту ссылку, по которой сборщик собирался до него дойти. Без барьера сборщик до белого объекта не доберётся, посчитает мусором и подметёт, а приложение потом прочитает освобождённую память.
Барьер записи (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
}
До 1.8 стоял чистый барьер Dijkstra, и он охранял только записи в кучу, а записи в стек не барьерились. Из-за этого чёрный стек мог получить ссылку на белый объект незаметно для сборщика, и в конце цикла приходилось останавливать мир и пересканировать все стеки. Пауза росла вместе с числом горутин: 100 мс на большом сервисе были нормой. Гибридный барьер добавил Yuasa-часть, «снимок на начало цикла»: если ссылка была видна в момент старта, объект выживет в этом цикле независимо от того, куда её потом перевесили. Это позволило сканировать каждый стек ровно один раз и навсегда красить его в чёрный. Поэтому mark termination стал занимать десятки микросекунд и перестал зависеть от размера кучи и числа горутин.
Read barrier нужен перемещающим сборщикам (Java ZGC/Shenandoah): через него читатель узнаёт,
что объект уехал. Go неперемещающий, поэтому чтение указателя бесплатно.
По той же причине в Go нет уплотнения кучи, а unsafe.Pointer
вообще может существовать как рабочий инструмент.
Фазы цикла и где именно останавливается мир
В цикле GC четыре фазы. Две из них короткие и идут под stop-the-world, две другие работают
параллельно с приложением. Сам STW делает stopTheWorld: рантайм
выставляет флаг преемптивности, и каждая горутина останавливается в ближайшей безопасной
точке (с Go 1.14 преемпция асинхронная, по сигналу, поэтому цикл без вызовов функций
больше не подвешивает сборщик).
- Sweep termination (STW). Домести хвост спанов с прошлого цикла, включить write barrier, перевести мир в режим маркировки. Десятки микросекунд.
- Mark (конкурентно). Сканируются корни (глобальные переменные, стеки горутин,
каждый ровно один раз), затем обходится граф. Работают dedicated воркеры
(25% от
GOMAXPROCS) и fractional воркер, добирающий дробную часть. Есть ещё mark assist: горутина, аллоцирующая быстрее, чем сборщик успевает метить, сама делает пропорциональную часть маркировки прямо вmallocgc. - Mark termination (STW). Убедиться, что очередь пуста, выключить write barrier, посчитать цель следующего цикла (pacer). Тоже десятки микросекунд.
- Sweep (конкурентно и лениво). Спаны метутся не разом, а фоновой горутиной и по требованию: когда
mcacheпросит новый спан, тот подметается прямо перед выдачей. Поэтому «уборка» размазана по времени и почти не видна в профиле как отдельный пик.
«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
Цикл запускается в трёх случаях:
- По достижении цели кучи. Основной путь.
GOGCзадаёт, на сколько процентов куче разрешено вырасти относительно живых данных прошлого цикла (с Go 1.18 к ним прибавляются стеки и глобальные переменные):target = live + (live + стеки + глобальные) × GOGC/100. ПриGOGC=100(по умолчанию) цикл стартует, когда куча удвоилась. Следит за этим pacer: цели он буквально не дожидается, а стартует заранее, оценивая скорость аллокации, чтобы маркировка успела закончиться к моменту, когда куча дорастёт до цели. - Принудительно, вызовом
runtime.GC(): он блокирует вызывающую горутину до конца полного цикла (и, если предыдущий цикл ещё идёт, сначала дожидается его). - По таймеру:
sysmonфорсирует цикл, если GC не запускался 2 минуты (forcegcperiod). Так простаивающий сервис всё-таки возвращает память ОС и не держит мусор бесконечно.
GOMEMLIMIT (Go 1.19) добавляет второй, независимый триггер: мягкий лимит
на общий объём памяти рантайма. В него входят куча, стеки горутин, метаданные GC и
внутренние структуры. Когда суммарный объём подбирается к лимиту, pacer запускает циклы
чаще, вплоть до непрерывной работы, чтобы удержаться под ним. «Мягкий» означает, что рантайм
не убьёт программу и не откажет в аллокации: если живых данных больше лимита, он просто
будет молотить GC, а память всё равно вырастет.
GOGC задаёт высоту
зуба в процентах от живых данных, GOMEMLIMIT ставит абсолютный потолок
и ценой лишних циклов не пускает пилу выше.
«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] — жив весь big | slices.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) }
runtime.AddCleanupвыполняется параллельно. Раньше cleanup-ы разбирала та же одна горутина, что и финалайзеры, строго по очереди, и один медленный обработчик подвешивал все остальные. Теперь cleanup-ы раскладываются по нескольким горутинам, и очередь перестала быть узким местом. Финалайзеры по-прежнему идут в одной горутине. Для программы, которая вешает cleanup на каждый объект (буферы, дескрипторы, cgo-память), разница принципиальная.GODEBUG=checkfinalizers=1включает режим диагностики. Рантайм на каждом цикле печатает размер очереди и, главное, ловит классические ошибки: замыкание финалайзера, захватившее сам объект (тот самый «бессмертный объект»), и cleanup, привязанный к объекту, до которого можно дойти из самого cleanup-а. Найдя такое, рантайм показывает, где финалайзер создан, и валит программу фатальной ошибкой. Выглядит так:
Держать включённым в проде не надо (лишние проверки под stop-the-world на каждом цикле и падение процесса на первой находке), но интеграционные тесты с этим флагом прогнать стоит: так дёшево находится утечка, которая иначе живёт месяцами.$ 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
Финалайзер и AddCleanup нужны как страховка от чужой забывчивости, а не как
механизм освобождения ресурсов. Так их и применяют в стандартной библиотеке:
os.File закрывает дескриптор, если пользователь забыл Close().
Детерминированное освобождение в Go делается через Close() + defer,
и никак иначе. На собесе так и говори: «единственный корректный сценарий — подстраховка
и диагностика утечек, всё остальное — явный Close».
Вопросы
11Цвета
- Белый — бит маркировки не стоит, объекта нет в очереди: кандидат в мусор.
- Серый — бит стоит, объект в очереди работы: нашли, но поля ещё не прочитали.
- Чёрный — бит стоит, поля просканированы, из очереди вынут.
Сам цвет не поле объекта. Физически есть биты маркировки в
метаданных спана (gcmarkBits) и распределённая очередь работы: у каждого
P свой буфер gcWork, при переполнении он отдаётся в глобальную
очередь, откуда его крадут другие воркеры. За счёт этого маркировка и масштабируется по ядрам.
Ход цикла
- Корни (глобальные переменные, стеки всех горутин, регистры) красятся в серый.
- Пока очередь не пуста: достать объект, просканировать его поля по карте указателей типа, все белые дети — в серый и в очередь, сам объект — чёрный.
- Очередь пуста → всё белое недостижимо → 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 (STW). Домести спаны, оставшиеся от прошлого цикла (sweep ленивый,
хвост мог не разобраться), включить write barrier, перевести все
Pв режим маркировки. - Mark (конкурентно). Сканируются корни: глобальные переменные и стек каждой горутины
(ровно один раз за цикл; горутина при этом коротко приостанавливается, но общего STW нет).
Затем обход графа. Работают dedicated воркеры (25% от
GOMAXPROCS), fractional-воркер на дробную часть и mark assist у аллоцирующих горутин. - Mark termination (STW). Проверить, что очередь работы пуста, выключить write barrier, посчитать цель следующего цикла, сбросить статистику.
- Sweep (конкурентно и лениво). Спаны подметает фоновая горутина, а недометённый спан
подметается в момент, когда
mcacheпросит новый. Значит, стоимость размазана по времени и аллокациям и отдельного пика в профиле не даёт.
Как физически реализуется STW
stopTheWorld выставляет у каждого P флаг преемпции и ждёт, пока все
горутины дойдут до безопасной точки. До Go 1.14 такой точкой был только вызов функции, поэтому
«горячий цикл без вызовов» мог задержать весь STW на неопределённое время. Отсюда знаменитый баг с
зависанием на for {}. С 1.14 работает асинхронная преемпция: рантайм шлёт
потоку сигнал SIGURG и останавливает горутину практически где угодно.
У 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
(скажем, 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 почти непрерывно. Весь 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 set | GOGC вверх + 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 без cap | O(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 контейнера.
runtime.ReadMemStats останавливает мир на время копирования структуры. На
сервисе с тысячами горутин и экспортом раз в секунду это заметная доля пауз, причём
их создаёт сам мониторинг. runtime/metrics (Go 1.16+) читает те же данные
без STW и с версионированными именами метрик. Для новых сервисов выбор однозначен.
Корни, из которых тянутся ссылки
Глобальные переменные пакетов, стеки всех живых горутин, значения в sync.Pool,
объекты, зарегистрированные в рантайме (таймеры, тикеры, финалайзеры), а также всё, что
достижимо из этого транзитивно. Утечка = что-то из этого списка держит ссылку слишком долго.
Типовые причины
- Глобальный кэш/мапа без вытеснения. Только пишем, никогда не удаляем. Есть и отдельный
нюанс:
deleteиз мапы память не возвращает, и мапа, разросшаяся до миллиона записей, после очистки держит таблицу прежнего размера. Вернуть память можно только одним способом: создать новую мапу. - Утечка горутин в проде встречается чаще всех. Горутина, навсегда вставшая на отправке в
небуферизованный канал или на
selectбезctx.Done(), держит свой стек (минимум 2 КБ, часто больше) и всё, до чего дотягивается из него. Тысяча зависших горутин с телом запроса внутри съедает сотни мегабайт. - Подслайс держит весь массив.
small := big[:10]—capостался отbig, значит жив весь исходный массив. - Подстрока держит исходную строку по той же механике.
- «Хвост» слайса после удаления.
s = s[:len(s)-1]оставляет указатель в подложке массива: элемент недоступен, но достижим. - Забытые
time.Ticker/time.AfterFunc. Тикер безStop()до Go 1.23 жил в рантайме вечно. С 1.23 недостижимые таймеры иTickerсобираются и безStop, а вотAfterFuncдо срабатывания по-прежнему держит замыкание со всем захваченным. - Не закрытый
http.Response.Bodyдержит соединение, буферы чтения и мешает переиспользованию keep-alive. - Замыкание, захватившее лишнее. Колбэк, положенный в долгоживущую структуру, тянет за собой весь контекст запроса.
- Растущий контекст: цепочка
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.
-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.Шесть причин не полагаться
- Вызова может не быть вообще. При завершении программы финалайзеры не запускаются: если объект дожил до выхода, функция не выполнится.
- Время не определено. Между смертью объекта и вызовом может пройти сколько угодно циклов GC. Для файловых дескрипторов и соединений это означает исчерпание лимита ОС задолго до срабатывания.
- Объект живёт минимум на цикл дольше. Первый цикл обнаруживает недостижимость и ставит объект в очередь финалайзеров, откуда тот снова становится достижимым (воскрешение). Освободит его только следующий цикл.
- Циклы не собираются. Если объекты с финалайзерами ссылаются друг на друга, не освободится ни один и не вызовется ни один финалайзер.
- Легко случайно сделать объект бессмертным. Замыкание, захватившее сам
obj, держит ссылку на него от корня очереди финалайзеров, и объект не умрёт никогда. Классический баг. - Выполняется в отдельной горутине и последовательно для всех объектов: медленный финалайзер блокирует остальные, а паника в нём валит процесс.
// так нельзя: объект никогда не умрёт
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формально не удалён, но в новом коде считается устаревшим.
Режим ловит те самые два бага, о которых шла речь выше: замыкание финалайзера,
захватившее сам объект, и 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, сети | по запросу, за интервал | «где именно теряется время» на таймлайне |
До 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
- Импорт
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.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). И это
правильный дефолт: по флеймграфу ответ «где горячо» читается за секунды, а граф вызовов на
большом сервисе превращается в стену из сотен узлов.
Алгоритм: «сервис ест много CPU»
- Убедиться, что это сервис.
top/kubectl top pod: точно ли наш процесс, точно ли user-time, а не iowait и не сосед по ноде. - Снять CPU-профиль под нагрузкой, 30 секунд:
go tool pprof -http=:8080 '...profile?seconds=30'. Профиль без нагрузки бесполезен. - Посмотреть
top -cumи флеймграф. Три типовых картины:- широко
runtime.gcBgMarkWorker,mallocgc,memmove: значит, проблема в аллокациях, а не в CPU, переходи к heap-профилю; - широко
runtime.mapaccess,runtime.growslice,reflect.*,encoding/json: виноваты структуры данных и сериализация; - широко
runtime.futex,lock2,selectgo: упёрлись в синхронизацию, снимайmutex/blockиtrace.
- широко
listпо подозреваемой функции, чтобы найти конкретные строки.- Написать бенчмарк, воспроизводящий горячий путь, зафиксировать базу.
- Починить, сравнить
benchstat-ом, выкатить, снять профиль снова. - Если код уже оптимален, проверить уровнем выше, не делаем ли мы вообще лишнюю работу (кэш, батчинг, отказ от сериализации, другой формат).
Алгоритм: «сервис ест много памяти»
- Разделить три диагноза. Растёт
/gc/heap/live(реально больше живых данных) или только RSS (память не отдана ОС)? Во втором случае утечки нет. - Проверить горутины первым делом:
/debug/pprof/goroutine?debug=1. Если их десятки тысяч и число растёт, утекают горутины, а не память, и искать надо незакрытые каналы и запросы без таймаута. - Два heap-снапшота с интервалом и
-base:go tool pprof -base h1 h2, режимinuse_space. - Разделить утечку и кэш: у кэша рост выходит на плато, а утечка растёт линейно, пропорционально числу запросов.
listиtracesпо подозреваемой функции: кто держит ссылку.- Проверить типовой список: глобальные мапы, подслайсы большого массива, забытые
Ticker, незакрытыеresp.Body, хвост слайса после удаления. - Если утечки нет, а памяти всё равно много, значит, такой профиль нагрузки: снижай аллокации,
ставь
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) })
Если «латентность плавает, а 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.
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)
}
}
У первой реализации 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 берёт
-count=N прогонов, считает медиану, разброс и p-value по критерию
Манна-Уитни. Если p > 0.05, он честно печатает ~, то есть «разницы нет».
Фраза «ускорил на 3%» без benchstat на собесе звучит как «я померил шум».
- Dead code elimination: результат не используется, и вызов удалён.
- Constant folding. Аргумент константный, и компилятор посчитал всё на этапе компиляции.
- Инлайнинг: в бенчмарке функция инлайнится, а в реальном коде (вызов через интерфейс) нет.
- Идеальный кэш. Одни и те же данные миллион раз лежат в L1, а в проде за ними приходится ходить в память. Реальная разница легко доходит до 10 раз.
- Идеальный предсказатель ветвлений: одинаковый вход делает все ветки предсказуемыми.
- Подготовка внутри цикла: меришь
makePayload, а неEncode. И наоборот:sync.Poolи прогретые буферы дают ноль аллокаций на второй итерации, которых в проде не будет. - Среда: 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(), а не хардкодят.
До 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приrunning≈GOMAXPROCSозначает, что процессора не хватает, и объясняет выросшую/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.
Оговорка из описаний самих метрик: снимки состояний приблизительные и не обязаны в сумме сходиться с общим числом горутин, потому что читаются без остановки мира, а горутины в этот момент меняют состояние. Для мониторинга этого достаточно, для арифметики нет.
Вопросы
6block и mutex
выключены по умолчанию, а goroutineleak появился в Go 1.27. С прода снимаются
HTTP-ручкой из net/http/pprof, поднятой на отдельном локальном порту.
Читаются через top -cum → list → флеймграф.Профили
cpu: сэмплирование стеков поSIGPROF100 раз в секунду. Снимается за интервал, обязательно под нагрузкой.heapпро аллокации, у него четыре режима:inuse_space,inuse_objects,alloc_space,alloc_objects. Сэмплирование по умолчанию раз в 512 КБ.goroutineдаёт полный снимок стеков всех живых горутин. Долго был главным инструментом против утечки горутин.goroutineleak1.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
- Подтвердить:
kubectl top pod, user-time именно нашего процесса, не iowait, не сосед по ноде, не throttling из-за CPU-лимита (container_cpu_cfs_throttled). - Снять CPU-профиль под нагрузкой:
profile?seconds=30. top -cum+ флеймграф. Три диагноза по тому, что широкое:gcBgMarkWorker,mallocgc,memmove→ это аллокационная проблема, переходим к heap-профилю в режимеalloc_space;encoding/json,reflect,mapaccess,growslice→ сериализация и структуры данных;futex,lock2,selectgo,chanrecv→ синхронизация: снимаемmutex,block,trace.
listпо функции, чтобы увидеть конкретные строки.- Написать бенчмарк на горячий путь, зафиксировать базу (
-count=10). - Починить, сравнить
benchstat, выкатить, снять профиль ещё раз. - Если код уже оптимален, подняться на уровень выше: кэш, батчинг, отказ от лишней работы, другой формат обмена. Быстрее всего выполняется работа, которую не делают.
Много памяти
- Разделить три диагноза: растёт
/gc/heap/live, или растёт только RSS (память просто не отдана ОС, и это не утечка), или это нормальный профиль нагрузки. - Сразу проверить горутины:
goroutine?debug=1. Растущее число означает утечку горутин, и дальше ищи каналы без читателя и запросы без таймаута. - Два heap-снапшота с интервалом 10–30 минут, сравнить:
go tool pprof -base h1.pprof h2.pprof, режимinuse_space. - Отличить кэш от утечки: кэш выходит на плато, утечка растёт линейно от числа запросов.
list/tracesпо подозреваемому: кто именно держит ссылку.- Пройтись по типовому списку: глобальные мапы без вытеснения, подслайсы большого массива,
забытые
Ticker, незакрытыеresp.Body, хвост слайса, жирные замыкания. - Если утечки нет, снижать аллокации, ставить
GOMEMLIMIT, крутитьGOGC. Именно в таком порядке.
Не список команд, а ветвление: «если в профиле широкий gcBgMarkWorker, то дело не в CPU, а в аллокациях, и лечится оно в другом месте». И честную границу: «сначала я подтверждаю проблему метрикой, потому что половина обращений „сервис тормозит“ оказывается проблемой соседнего сервиса или сети». Ответ «я бы посмотрел pprof» без ветвления считается слабым.
Что видно
- 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 реально загружает все ядра.
Трассировка не бесплатна, но в 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
- Первый прогон с
b.N = 1. - Если прогон занял меньше
benchtime, следующее N считается какN × (benchtime / elapsed) × 1.2без округления (круглые числа были до Go 1.13) и не растёт больше чем в 100 раз за шаг. - Так до тех пор, пока прогон не дотянет до
benchtime(потолок 1e9 итераций). - Отчёт печатается по последнему прогону:
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 берёт
-count=N прогонов, считает медиану, разброс и p-value; если разница
статистически незначима, он печатает ~. Кто говорит «стало быстрее на 3%»
без benchstat, тот говорит про шум, и на собесе это слышно.
Что искажает результат
- Dead code elimination. Результат не используется, поэтому вызова просто нет. Симптом: подозрительно круглые доли наносекунды.
- Constant folding. Аргумент задан литералом, и компилятор посчитал всё на этапе компиляции. Лечится входом из переменной, которую компилятор не видит насквозь.
- Инлайнинг, которого не будет в проде. В бенчмарке функция вызывается напрямую и инлайнится; в приложении она спрятана за интерфейсом: вызов косвенный, инлайна нет, и escape analysis решает иначе.
- Идеальная локальность. Один и тот же вход миллион раз лежит в L1. В проде это промах в память: 1 нс против 80 нс. Разница в разы, и она не в коде.
- Идеальный предсказатель ветвлений. Одинаковые данные делают все
ifбесплатными, а на реальном распределении получаешь mispredict на каждой второй итерации. - Прогретое состояние.
sync.Pool, преаллоцированные буферы, заполненные мапы дают ноль аллокаций со второй итерации. В проде каждый запрос и есть первая итерация. - Подготовка внутри цикла: измеряешь генератор данных, а не функцию. И обратная
ошибка:
StopTimer/StartTimerна каждой итерации сами по себе дороже измеряемой операции. - Среда. Turbo boost, тепловой троттлинг, шумный CI, соседи по ноде, разный
GOMAXPROCS, включённый профайлер. Отсюда обязательные-count=10иbenchstat. - Не тот масштаб. Функцию ускорили в 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 первые из них ложатся в буфер
на стеке, и аллокаций в куче меньше, но копии и мусор остаются.
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.
Описанное выше тут уже сделали за тебя. Старый
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)) }
Обычные []byte(s) и string(b) копируют данные, и за счёт этого строки в Go
остаются неизменяемыми и безопасными. unsafe-версии делят одну память, и правила у них жёсткие:
b2s: полученную строку можно использовать только пока никто не пишет в исходный слайс. Поменяешь байт, и строка молча изменится, а если она успела стать ключом мапы, мапа сломана навсегда.s2b: в полученный слайс нельзя писать вообще никогда. Строковые литералы лежат в read-only секции, и запись туда дастSIGSEGV(на macOS SIGBUS). Плюс интернированные компилятором строки общие на всю программу.- Нельзя отдавать результат наружу пакета и нельзя класть в долгоживущие структуры.
Допустимо: горячий путь внутри одного пакета, короткое время жизни, есть бенчмарк,
доказывающий выигрыш, и тесты гоняются под -race, который включает и проверку -d=checkptr.
Недопустимо: «мне показалось, что так быстрее». На собесе отвечай так:
«знаю как, применял, но только за границей горячего цикла и с комментарием, почему это
безопасно именно здесь».
В 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-ом. Значит, порядок полей напрямую
определяет размер.
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: два счётчика разных горутин
в одной кэш-линии (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.
Сначала измерь
Этот порядок стоит проговорить вслух, именно за него ставят плюс:
- Есть ли проблема? SLO, метрики, жалобы. Без цифры задачи нет.
- Где именно? Профиль, а не интуиция. Ускорять то, что занимает 0.5% профиля, бессмысленно по закону Амдала.
- Алгоритм и архитектура. Убрать N+1 запрос, добавить индекс, закэшировать, батчить, не делать работу вовсе. Здесь лежат разы, а не проценты.
- Аллокации. Преаллокация, переиспользование буферов, меньше указателей. Здесь лежат десятки процентов.
- Микрооптимизации. Порядок полей, инлайн, unsafe, PGO. Здесь лежат единицы процентов.
- Проверить в проде. Бенчмарк доказал, что функция стала быстрее; только профиль после выката доказывает, что стало быстрее приложение.
Каждая оптимизация создаёт долг: код усложняется, и следующий человек может его сломать, не понимая, зачем так написано. Поэтому: (1) оптимизируй только измеренные горячие места, а их обычно 1–2% кода; (2) оставляй рядом комментарий с цифрами и ссылкой на бенчмарк; (3) оставляй сам бенчмарк в репозитории, чтобы регрессию поймал CI; (4) если выигрыш меньше разброса измерений, откатывай, даже если «красиво». Для собеса: «по умолчанию пишем читаемо, а нечитаемость надо заслужить цифрами».
Вопросы
6append при нехватке ёмкости выделяет новый массив
и копирует туда старый. Знаешь итоговый размер — скажи его сразу:
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 expressiona[: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)) }
b2s: строка валидна, только пока никто не пишет в исходный слайс;
если такая строка попала ключом в мапу и байт изменился, мапа сломана навсегда.
s2b: в результат нельзя писать никогда, потому что литералы лежат в read-only
секции и запись даст SIGSEGV (на macOS SIGBUS). Ни то, ни другое нельзя отдавать наружу пакета
или класть в долгоживущие структуры. Допустимо: короткий горячий путь внутри пакета,
есть бенчмарк с выигрышем, тесты гоняются под -race (он включает и
-d=checkptr), рядом комментарий с обоснованием.
Уменьшая аллокации, ты бьёшь сразу по трём статьям: время на сам
mallocgc, частота сборок (темп аллокации напрямую задаёт, как быстро
куча дорастёт до цели GOGC) и стоимость маркировки. Причём последнее
зависит не от числа объектов, а от числа указателей: объект без указателей
попадает в noscan-класс, и сборщик его вообще не сканирует. Отсюда неочевидный приём:
[]Item вместо []*Item и индексы int32 вместо
указателей дают одну аллокацию вместо n и ноль работы маркировщику.
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{}стоит не последним, размер не меняется. - Размер не равен «сумме полей». У
string16 байт (указатель + длина), у[]T24, уinterface{}и любого интерфейса 16, уmap/chan/func8. Их содержимое лежит отдельно в куче,unsafe.Sizeofего не считает. - Не путать выравнивание с числом указателей. Иногда важнее не размер, а то, что структура вообще без указателей попадает в noscan-класс и не сканируется сборщиком.
False sharing ставит обратную задачу: тут padding не убирают, а добавляют.
Два счётчика разных горутин в одной кэш-линии (64 байта) заставляют ядра гонять линию
туда-сюда протоколом когерентности; переменные независимы, а throughput падает в разы.
Помогает вставка _ [64]byte между полями, как в
runtime и в шардированных счётчиках. Отдельная деталь: int64-поле,
к которому применяют atomic.AddInt64, на 32-битных платформах
нужно вручную выравнивать по 8 байт (первым полем структуры), иначе будет паника, и это
правило действует до сих пор. С Go 1.19 вместо этого берут atomic.Int64 и
atomic.Pointer[T]: выравнивание в них гарантировано, а копию ловит
vet.
«Компилятор поля не переставляет, поэтому за раскладку отвечаю я как автор типа.
Сортирую по убыванию выравнивания, проверяю fieldalignment в CI. Но делаю
это только для типов, которых в памяти миллионы. В остальных случаях читаемая
группировка полей дороже восьми байт.»
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, так что часть «слишком сложных» функций начинает инлайниться
сама, без переписывания.
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, растущая стоимость железа, жалоба. Без цифры задачи нет. «Кажется, тут медленно» ещё не задача.
- Где именно? Профиль, а не интуиция. По закону Амдала функция, занимающая 2% времени, даже ускоренная вдвое, даст 1%. Это не окупает ни дня работы, ни усложнения кода.
- Алгоритм и архитектура. Убрать N+1 запрос, добавить индекс, закэшировать, сбатчить, не делать работу вовсе. Здесь лежат разы. Быстрее всего работает код, который не выполняется.
- Аллокации. Преаллокация, переиспользование буферов, меньше указателей, меньше лишних копий. Здесь лежат десятки процентов, и заодно снижается нагрузка на GC.
- Микрооптимизации. Порядок полей, инлайн, PGO, unsafe. Здесь лежат единицы процентов.
- Проверить в проде. Бенчмарк доказывает, что функция стала быстрее; что стало быстрее приложение, доказывает только метрика после выката.
Измерять — значит измерять статистически
$ 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%». На таком стенде это шум.
- «Оптимизировал, но метрики после выката не смотрел», то есть работу не довёл до конца.
«По умолчанию пишу читаемо, а нечитаемость надо заслужить цифрами. Порядок такой: метрика показала проблему, профиль показал место, бенчмарк зафиксировал базу, правка дала статистически значимый выигрыш, комментарий объяснил зачем, метрика в проде подтвердила. Если хоть один шаг не сошёлся, откатываю.»