Инлайнинг в Go: что смотреть на ревью горячих функций
Компилятор Go молча решает, встроить функцию по месту вызова или нет. На горячих путях это разница в разы. Разбираем, что искать в коде и в выводе -gcflags, чтобы ревью ловило пропущенный инлайнинг.

Функция на горячем пути перестала встраиваться после безобидной правки — кто-то добавил в неё defer для закрытия ресурса. Бенчмарк на том же коммите просел: аллокации выросли, время на операцию поднялось с 12 до 40 наносекунд. Компилятор Go принимает решение об инлайнинге сам, по числовому бюджету, и это решение легко сломать невидимой глазом строкой. На ревью такие вещи ловятся не «качеством кода», а конкретными вопросами к автору и к выводу компилятора.
Речь про Go 1.20–1.22. Правила инлайнинга менялись между версиями (в 1.20 появился инлайнинг функций с циклами for-range, в более ранних их не встраивали вовсе), поэтому цифры и запреты ниже проверяйте на своей версии командой go version.
Что такое бюджет инлайнинга и почему он важен на ревью
Компилятор считает «стоимость» тела функции в условных единицах и сравнивает с порогом. Порог по умолчанию — 80 единиц (константа в исходниках компилятора, флаг -l управляет включением инлайнинга целиком). Функция дешевле порога — кандидат на встраивание в место вызова. Дороже — вызов остаётся обычным, со всеми затратами на пролог, эпилог и работу со стеком.
Инлайнинг сам по себе не самоцель. Он важен там, где функция вызывается миллионы раз в секунду: короткие геттеры, преобразования, предикаты в горячих циклах. Встроенная функция открывает дорогу другим оптимизациям — escape-анализ может оставить объект на стеке вместо кучи, а это минус одна аллокация на вызов. Именно связка «инлайнинг → escape на стек» даёт основной выигрыш, а не сам факт отсутствия call-инструкции.
На ревью вы не считаете единицы в уме. Вы спрашиваете компилятор и смотрите, изменилось ли решение по сравнению с базовой веткой.
Как узнать решение компилятора
Флаг -m печатает решения об оптимизациях. Двойной -m объясняет, почему функция не была встроена:
go build -gcflags='-m -m' ./... 2>&1 | grep -i inlin
В выводе вы увидите строки двух типов. «can inline processRow» — функция признана встраиваемой. «cannot inline handleRequest: function too complex: cost 210 exceeds budget 80» — не влезла в бюджет, и указана её стоимость. Второй вид строк — то, ради чего вы запускаете команду на ревью.
Что запрещает инлайнинг — список для проверки
Часть конструкций делает функцию невстраиваемой независимо от размера. Их наличие в горячей функции — повод для вопроса автору.
- Раньше —
defer. До Go 1.20 любойdeferвнутри функции запрещал её инлайнинг целиком. Начиная с 1.20 компилятор научился встраивать некоторые функции сdefer, но не все. Проверяйте по выводу-m, а не по памяти. - Рекурсия. Функция, вызывающая саму себя, не встраивается.
- Замыкания и вызовы через интерфейс. Вызов метода через интерфейсное значение — динамический, компилятор не знает конкретный тип и не встраивает. Прямой вызов метода конкретного типа встроить может.
- Слишком большое тело. Длинная функция с ветвлениями и вызовами превышает бюджет 80. Каждый вложенный вызов добавляет к стоимости.
- Отдельные конструкции. Набор запрещённых узлов менялся между версиями. Не заучивайте список — запускайте
-m -mи читайте причину.
Инлайнинг — не то, что вы читаете глазами в диффе. Это то, что говорит компилятор на этом коммите против базовой ветки. Ревью без сравнения вывода -gcflags='-m' — это ревью догадок.
Конкретные формулировки замечаний
Расплывчатое «посмотри на производительность» автор проигнорирует. Работают привязанные к строке замечания с проверяемым критерием.
- «В
parseHeaderдобавленdefer f.Close(). Функция вызывается в цикле разбора на каждой строке. Проверьgo build -gcflags='-m' ./parser— раньше было can inline, сейчас cannot inline. Еслиdeferтут не нужен для корректности, вынеси закрытие наружу.» - «Метод
Value()стал принимать интерфейс вместо конкретного типа. Вызовы через интерфейс не инлайнятся, а он на горячем пути аллокатора. Нужен ли здесь полиморфизм или можно оставить конкретный тип?» - «Функция
computeвыросла до cost 140 при бюджете 80 после добавления валидации. Вынеси редкую ветку валидации в отдельную не-встраиваемую функцию, чтобы горячая часть осталась дешёвой.» - «Бенчмарк
BenchmarkEncodeв этом PR: было 0 allocs/op, стало 2 allocs/op. Похоже, объект начал убегать в кучу из-за пропавшего инлайнинга. Приложи вывод-gcflags='-m'до и после.»
Последняя формулировка — самая сильная. Аллокации в бенчмарке видны без знания внутренностей компилятора, а рост с нуля до двух почти всегда означает потерянный escape-на-стек.
Приём «горячее и холодное»: разделение функции
Частая ошибка — держать в одной функции дешёвый частый путь и дорогую редкую ветку. Стоимость обработки ошибок и логирования суммируется и выбивает функцию из бюджета.
// Плохо: логирование раздувает cost, инлайнинг пропадает
func lookup(cache map[string]int, key string) (int, bool) {
v, ok := cache[key]
if !ok {
// редкая ветка с большим телом тянет стоимость вверх
log.Printf("cache miss for key %q, cache size %d", key, len(cache))
metrics.Inc("cache.miss")
}
return v, ok
}
// Хорошо: горячая часть маленькая и встраиваемая,
// редкая ветка вынесена в отдельную функцию
func lookup(cache map[string]int, key string) (int, bool) {
v, ok := cache[key]
if !ok {
onMiss(cache, key) // отдельный вызов, не мешает инлайнингу lookup
}
return v, ok
}
func onMiss(cache map[string]int, key string) {
log.Printf("cache miss for key %q, cache size %d", key, len(cache))
metrics.Inc("cache.miss")
}
После разделения lookup попадает в бюджет и встраивается, а onMiss вызывается только в редком случае промаха — его невстраиваемость никого не волнует. На ревью это стандартный совет для функции, у которой большая часть тела — обработка нечастого случая.
Как проверить конкретную функцию
Чтобы не листать весь вывод, отфильтруйте по имени:
go build -gcflags='-m -m' ./pkg/cache 2>&1 | grep 'lookup'
Если увидите can inline lookup — цель достигнута. Если cannot inline lookup: ... — читайте причину после двоеточия, там указана либо стоимость, либо конкретная запрещающая конструкция.
Сравнение того, что мешает инлайнингу
| Конструкция в теле функции | Влияние на инлайнинг | Что делать на ревью |
|---|---|---|
| Вызов метода через интерфейс | Запрещает (вызов динамический) | Спросить, нужен ли полиморфизм на горячем пути |
defer | Зависит от версии; часто запрещает | Проверить вывод -m, рассмотреть вынос |
| Рекурсивный вызов | Запрещает всегда | Оставить как есть или итеративная версия |
| Крупное тело (cost > 80) | Не влезает в бюджет | Разделить на горячую и холодную части |
| Короткий геттер/предикат | Обычно встраивается | Следить, чтобы правки не раздули тело |
Что мерить, а не гадать
Инлайнинг доказывается бенчмарком, а не рассуждением. Прогоняйте таблицу до и после правки на одной машине:
go test -bench=. -benchmem -count=10 ./pkg/cache > new.txt
# на базовой ветке — old.txt тем же способом
benchstat old.txt new.txt
Инструмент benchstat ставится командой go install golang.org/x/perf/cmd/benchstat@latest. Он показывает ns/op, allocs/op и B/op со статистической значимостью, а не единичный шумный замер. На ревью просите не одно число, а такой diff. Рост allocs/op — самый явный сигнал потерянной оптимизации.
Границы: где инлайнинг не поможет и где вредит
Инлайнинг раздувает бинарник: тело функции копируется в каждое место вызова. Для функции, вызываемой в сотнях мест и не на горячем пути, агрессивная погоня за инлайнингом увеличит размер кода и давление на инструкционный кеш без выигрыша. Не требуйте встраивания там, где нет замеров, показывающих, что функция горячая.
Профиль важнее интуиции. Функция, которая «выглядит горячей», может занимать 0.1% CPU по профилю pprof. Прежде чем воевать за её инлайнинг на ревью, посмотрите, где реально проводит время программа. Инлайнинг холодной функции — потраченное время автора и лишний шум в диффе.
С Go 1.20+ появился профиль-ориентированный инлайнинг (PGO): компилятор с профилем встраивает более агрессивно горячие вызовы, даже превышающие обычный бюджет. Если проект собирается с PGO, решения об инлайнинге зависят от профиля сборки — это отдельная тема, здесь её не разворачиваем.
Что делать дальше
Заведите в CI шаг, который на PR прогоняет go build -gcflags='-m' для горячих пакетов и сравнивает список can-inline функций с базовой веткой. Регрессия «функция перестала встраиваться» будет видна в проверке, а не всплывёт бенчмарком через месяц. Для критичных путей добавьте бенчмарки с -benchmem и порогом по allocs/op — это ловит потерю escape-на-стек. И держите под рукой профиль pprof прода: он отвечает на вопрос, за инлайнинг какой функции вообще стоит воевать.
Логотип и фирменный стиль: Три концепции разного характера, палитра с обоснованием, проверка на читаемость в малом размере и в один цвет.
Спросить за 60 ₽Источники: Compiler And Runtime Optimizations — Go source, cmd/compile, Profile-guided optimization — Go documentation
Частые вопросы
У меня Go 1.19, а не 1.20+. Что из статьи не сработает?
Я добавил замечание, а вывод -m не изменился. Значит, всё в порядке?
Стоит ли требовать инлайнинг для всех маленьких функций?
Можно ли принудительно заставить компилятор встроить функцию?
В проде функция всё равно медленная, хотя -m показывает can inline. Почему?
benchstat показывает разницу, но с пометкой о низкой значимости. Верить ли?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.