Go 1.25 сам читает лимит CPU, а automaxprocs молча это выключает
- четверг, 8 октября 2026 г. в 00:00:06
Привет, Хабр!
В main.go почти любого Go-сервиса, который живёт в Kubernetes, — вполне типичного стека для микросервисной архитектуры — лежит пустой импорт go.uber.org/automaxprocs. Его давно никто не трогал, и половина команды уже не помнит, зачем он там.
Попал он туда по делу.
Рантайм Go считал GOMAXPROCS по числу логических процессоров машины и про лимит пода ничего не знал, так что сервис с limits.cpu: 2 на 64-ядерной ноде:
поднимал 64 планировщика;
раскладывал горутины по 64 очередям;
пытался занять 64 процессора, которых ему никто не выдавал.
Библиотека от Uber читала cgroup и выставляла GOMAXPROCS руками, и 9 лет это был правильный совет.
В Go 1.25, который вышел в августе 2025, рантайм научился читать cgroup сам.
Казалось бы, библиотека после этого просто перестала быть нужной.
Вышло хуже.
Она выставляет GOMAXPROCS явным вызовом. Рантайм принимает это за решение человека и до конца жизни процесса значение больше не трогает.
Формула лежит в доккомментарии к runtime.GOMAXPROCS: минимум из:
числа логических процессоров;
числа процессоров в маске affinity;
лимита пропускной способности cgroup.
Дальше идут две оговорки, и вся разница между старым советом и новым поведением держится на них.
Дробный лимит округляется вверх.
А получившееся значение не опускается ниже 2, пока самих процессоров в системе не меньше двух.
В коде это выглядит так:
func adjustCgroupGOMAXPROCS(procs int32, cpu cgroup.CPU) int32 { limit, ok, err := cgroup.ReadCPULimit(cpu) if err == nil && ok { limit = ceil(limit) limit = max(limit, 2) if int32(limit) < procs { procs = int32(limit) } } return procs }
У automaxprocs обе половины арифметики другие.
Округляет он вниз, через int(math.Floor(v)), и останавливается на единице:
cfg := &config{ procs: iruntime.CPUQuotaToGOMAXPROCS, roundQuotaFunc: iruntime.DefaultRoundFunc, // это math.Floor minGOMAXPROCS: 1, } // ... runtime.GOMAXPROCS(maxProcs)
Проверить это можно за пару минут.
Ставим квоту 1,5 процессора на двухпроцессорной машине и запускаем два бинарника, которые отличаются только импортом:
без automaxprocs GOMAXPROCS=2 NumCPU=2 maxprocs: Updating GOMAXPROCS=1: determined from CPU quota с automaxprocs GOMAXPROCS=1 NumCPU=2
Полтора вверх дают 2, полтора вниз дают 1.
Для limits.cpu: 1500m, значения вполне обычного в чартах, число планировщиков отличается вдвое в зависимости от того, остался ли в main.go пустой импорт.
На квоте меньше процессора расхождение такое же, только упираются обе стороны в свои минимумы.
100m рантайм поднимает до 2, а библиотека опускает до 1: floor(0.1) даёт ноль, который подтягивается до её собственного минимума.
В лог она это пишет другой строчкой, using minimum allowed GOMAXPROCS вместо determined from CPU quota.
Цифра берётся из лимита, а не из запроса.
В доках написано: значение «usually corresponds to the "CPU limit" option, not "CPU request"».
У сервиса без limits.cpu квоты в cgroup нет, читать нечего, и GOMAXPROCS остаётся равным числу процессоров ноды, так что команды, которые специально не ставят лимиты, чтобы не ловить троттлинг, от Go 1.25 не получают ни нового числа, ни автообновления.
Для них и automaxprocs всегда был пустышкой: смотрит он в ту же квоту и при её отсутствии пишет в лог, что уходит ни с чем.
При GOMAXPROCS=1 в планировщике Go не остаётся параллелизма вообще.
Сборщик мусора от этого не исчезает. Его воркеры встают в ту же единственную очередь, что и горутины приложения, и рантайм гоняет единственный процессор между сборкой и полезной работой.
Со стороны приложения это выглядит как короткие паузы на ровном месте, хотя никакого stop-the-world там нет.
Второй процессор нужен затем, чтобы сборщику было где работать, не отбирая очередь у приложения.
Дробная квота добавляет второй довод.
Авторы предложения исходят из того, что лимит меньше процессора ставят под всплесковую нагрузку: ровную в такой лимит не уложить, CFS прижмёт её на первом же периоде.
А у всплесковой внутри периода остаётся запас, и рантайм этим запасом пользуется — берёт 2 процессора, когда есть что считать, и в среднем всё равно остаётся в квоте.
Округление вверх работает на то же самое.
Потолок в 1 процессор при квоте 1,5 оставил бы половину оплаченного времени невыбранной, а 2 процессора берут квоту целиком.
Вторая половина изменения в Go 1.25 весит больше первой.
Рантайм читает лимит не один раз на старте, а возвращается к файлу снова и снова, пока процесс жив.
Стоит это недорого: файл лимита открывается при запуске и остаётся открытым, а sysmon раз в секунду перечитывает его с нулевого смещения:
if debug.updatemaxprocs != 0 && lastgomaxprocs+1e9 <= now { sysmonUpdateGOMAXPROCS() }
Из-за открытого дескриптора в strace видно один openat, а дальше идут pread64 по тому же номеру.
На занятой программе они выстраиваются секунда за секундой:
14:09:01.790180 pread64(3</sys/fs/cgroup/cpu/gotest/cpu.cfs_quota_us 14:09:01.799745 pread64(3</sys/fs/cgroup/cpu/gotest/cpu.cfs_quota_us 14:09:02.811929 pread64(3</sys/fs/cgroup/cpu/gotest/cpu.cfs_quota_us 14:09:03.820085 pread64(3</sys/fs/cgroup/cpu/gotest/cpu.cfs_quota_us 14:09:04.826949 pread64(3</sys/fs/cgroup/cpu/gotest/cpu.cfs_quota_us 14:09:05.828010 pread64(3</sys/fs/cgroup/cpu/gotest/cpu.cfs_quota_us ... 14:09:09.843106 pread64(3</sys/fs/cgroup/cpu/gotest/cpu.cfs_quota_us
Имя файла зависит от версии cgroup.
В v2 это cpu.max: квота и период стоят парой в одной строке.
В v1 это два отдельных файла, cpu.cfs_quota_us и cpu.cfs_period_us.
Рантайм работает с обоими и выбирает нужный по /proc/self/mountinfo.
Если программа ничего не делает, перечитываний не будет совсем: sysmon на простое уходит в глубокий сон.
Пустой импорт automaxprocs подтягивает init, который вызывает maxprocs.Set, а тот в конце работы зовёт runtime.GOMAXPROCS(maxProcs).
Вместе с числом этот вызов поднимает флаг:
sched.customGOMAXPROCS = true
Дальше всё решает один if.
Sysmon на каждом тике смотрит на флаг первым делом и, если тот поднят, разворачивается, не дочитав лимит:
// No update if GOMAXPROCS was set manually. lock(&sched.lock) custom := sched.customGOMAXPROCS curr := gomaxprocs unlock(&sched.lock) if custom { unlock(&computeMaxProcsLock) return }
Снять флаг некому, он стоит до конца процесса.
Тот же strace на том же стенде, только бинарник собран с импортом:
14:09:10.154216 pread64(3</sys/fs/cgroup/cpu/gotest/cpu.cfs_quota_us 14:09:10.164698 pread64(3</sys/fs/cgroup/cpu/gotest/cpu.cfs_quota_us 14:09:10.170484 read(5</sys/fs/cgroup/cpu/gotest/cpu.cfs_quota_us>, (дальше 8 секунд работы и ни одного обращения)
Видно 3 чтения на старте.
Два сделал рантайм, одно библиотека своим дескриптором.
Дальше квоту можно менять как угодно. GOMAXPROCS не шевельнётся.
Переменная окружения делает то же самое, только раньше и грубее.
Если хотите проверить себя шире, можно пройти короткий бесплатный вступительный тест. Он покажет текущий уровень и темы, в которых стоит разобраться глубже.
GOMAXPROCS в env деплоймента поднимает флаг до того, как рантайм успеет что-то прочитать, и файл лимита не открывается вовсе:
GOMAXPROCS=2 в окружении -> обращений к квоте: 0 GOMAXPROCS=2 + SetDefaultGOMAXPROCS() -> обращений к квоте: 9
Вторую строчку даёт новая функция из Go 1.25, runtime.SetDefaultGOMAXPROCS().
Она сбрасывает флаг и пересчитывает значение по умолчанию, игнорируя переменную окружения.
Нужна она в одном случае: GOMAXPROCS прописан в чарте, выпилить его сейчас нельзя, а автообновление хочется получить уже сегодня.
Путаница начинается, когда оба выключения стоят одновременно.
Если GOMAXPROCS стоит в окружении, automaxprocs его увидит и менять ничего не станет, написав в лог maxprocs: Honoring GOMAXPROCS="2" as set in environment.
По логу выходит, будто библиотека ни при чём, хотя перечитывания к этому моменту уже выключены переменной.
Отличить работающий случай от выключенного можно одной командой, без чтения кода: strace -f -y -e trace=pread64 ./app и грепнуть по cfs_quota или cpu.max.
Перечитывания раз в секунду означают, что рантайм делает свою работу.
Пусто после старта — значит, GOMAXPROCS выставили руками, и дальше остаётся найти кто.
Выкинуть импорт и разойтись не получится.
Число меняется, и в одну сторону это выигрыш, а в другую нет.
Возьмём ту же квоту 1,5 процессора и нагрузку, которая гребёт процессор непрерывно:
GOMAXPROCS=2 время 6581мс GC 782 цикла пауз всего 611.9мс троттлингов 65 в троттлинге 1685мс GOMAXPROCS=1 время 5954мс GC 511 циклов пауз всего 18.2мс троттлингов 0 в троттлинге 0мс GOMAXPROCS=2 время 6158мс GC 844 цикла пауз всего 362.4мс троттлингов 60 в троттлинге 1419мс GOMAXPROCS=1 время 5928мс GC 533 цикла пауз всего 17.0мс троттлингов 0 в троттлинге 0мс
Два планировщика на полутора процессорах обещают больше, чем ядро может выдать.
CFS режет процесс 60 раз за прогон и держит замороженным около 1,5 секунды.
Суммарные stop-the-world паузы вырастают в 20 с лишним раз, а по времени выходит даже медленнее.
nr_throttled в cpu.stat при GOMAXPROCS=1 остаётся нулём: один поток физически не съест больше одного процессора.
Теперь та же квота, но нагрузка всплеском:
8 клиентов;
каждый просит 1 мс процессора;
2 мс отдыхает.
Это уже похоже на обычный HTTP-сервис.
GOMAXPROCS=2 всего 2.2с p50 1.00мс p99 25.38мс p999 39.79мс макс 56.54мс троттлингов 20 GOMAXPROCS=1 всего 3.3с p50 1.00мс p99 83.29мс p999 113.11мс макс 154.10мс троттлингов 0
Хвост отличается в 3 с лишним раза.
И отличается в пользу того варианта, который троттлится.
Пока запросы приходят пачками, второй планировщик разбирает очередь параллельно и успевает отдать квоту до конца периода, а единственный складывает всё в одну очередь, и каждый следующий запрос ждёт, пока освободится предыдущий.
Оба поведения спрятаны за GODEBUG-настройками, а значит подчиняются общему правилу: умолчания GODEBUG берутся из строки go в go.mod главного модуля, а не из версии тулчейна.
В таблице настроек рантайма они стоят так:
{Name: "containermaxprocs", Package: "runtime", Changed: 25, Old: "0"}, {Name: "updatemaxprocs", Package: "runtime", Changed: 25, Old: "0"},
Changed: 25 означает, что умолчание поменялось в Go 1.25, а Old: "0" — что до этого обе настройки были выключены.
Соберём одну и ту же программу тулчейном Go 1.27, меняя только строку go, и посчитаем обращения к файлу квоты за 8 секунд работы:
go.mod: go 1.24 -> обращений к квоте: 1 go.mod: go 1.25 -> обращений к квоте: 10
Единственное обращение при go 1.24 — это стартовая проверка: рантайм один раз смотрит на cgroup, чтобы поднять счётчик /godebug/non-default-behavior/containermaxprocs:events в runtime/metrics, если включение настройки что-то изменило бы.
Самого поведения нет, но в метриках видно, что оно пригодилось бы.
Кстати, в комментарии рантайма счётчик назван cgroupgomaxprocs — имя отстало от кода, грепать надо по containermaxprocs.
Поднимать строку go ради этого не обязательно, настройку можно включить директивой в том же go.mod:
go 1.24 godebug containermaxprocs=1 godebug updatemaxprocs=1
Лимит читается только из того cgroup, в котором процесс лежит непосредственно, так что если на родительском cgroup лимит жёстче, действовать будет он, а рантайм его не увидит.
Авторы это признают.
В шапке дописано, что «container runtimes tend to hide parent cgroups from the container anyway»: изнутри контейнера родителя обычно и не видно.
Второе ограничение касается переездов.
Cgroup определяется один раз при старте, так что процесс, переехавший в другую группу на ходу, этого не заметит и продолжит читать файл старого.
Для Kubernetes это неважно, под не ездит.
А на своих песочницах и systemd-юнитах встретиться может.
automaxprocs тут ведёт себя не лучше: он тоже читает cgroup один раз и тоже только свой.
Пустой импорт — единственный вид зависимости, который невозможно заметить при чтении кода.
Он:
не вызывается;
не фигурирует в сигнатурах;
не ломает сборку, если его удалить;
и живёт в чужом main.go годами после того, как перестал быть нужен.
9 лет он делал полезное дело, а на Go 1.25 стал выключателем.
В README automaxprocs про это ничего нет: последний релиз v1.6.0 вышел 23 сентября 2024, библиотека не заархивирована и формально работает как написано.
Выпиливать её только начали.
Начинать поэтому надо не с удаления импорта, а с двух чисел:
какое GOMAXPROCS у сервиса сейчас;
какое получится без библиотеки.
На целом лимите они совпадут, и единственной разницей станет появившееся автообновление.
На дробном число изменится, и тогда полезно знать, ровная у сервиса нагрузка или всплесковая:
округление вверх на ровной процессорной нагрузке стоит троттлинга,
а на всплесковой возвращает втрое более короткий хвост.
Переменную GOMAXPROCS в чарте стоит поискать отдельно, она выключает то же самое, но хотя бы видна глазами.
Обновлять один тулчейн и считать, что поведение включилось вместе с ним, не стоит вообще: решает строка go в go.mod.
А сколько у вас сервисов, где automaxprocs всё ещё лежит в go.mod?

В микросервисах важно не только находить проблемы, но и не закладывать их на этапе проектирования. На бесплатном уроке разберём, как проектировать бизнес-логику, определять границы ответственности сервисов и принимать архитектурные решения, которые упрощают развитие системы.
22 октября в 19:00. «Основы проектирования бизнес-логики в микросервисной архитектуре». Записаться
А полный список вебинаров октября собрали в дайджесте.