Привет, Хабр!

В 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 всегда был пустышкой: смотрит он в ту же квоту и при её отсутствии пишет в лог, что уходит ни с чем.

Почему минимум 2, а не 1

При 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 с лишним раза.

И отличается в пользу того варианта, который троттлится.

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

Тулчейн обновили, go.mod забыли

Оба поведения спрятаны за 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 один раз и тоже только свой.

Библиотека кончилась, привычка осталась

Пустой импорт — единственный вид зависимости, который невозможно заметить при чтении кода.

Он:

  1. не вызывается;

  2. не фигурирует в сигнатурах;

  3. не ломает сборку, если его удалить;

  4. и живёт в чужом main.go годами после того, как перестал быть нужен.

9 лет он делал полезное дело, а на Go 1.25 стал выключателем.

В README automaxprocs про это ничего нет: последний релиз v1.6.0 вышел 23 сентября 2024, библиотека не заархивирована и формально работает как написано.

Выпиливать её только начали.

Начинать поэтому надо не с удаления импорта, а с двух чисел:

  • какое GOMAXPROCS у сервиса сейчас;

  • какое получится без библиотеки.

На целом лимите они совпадут, и единственной разницей станет появившееся автообновление.

На дробном число изменится, и тогда полезно знать, ровная у сервиса нагрузка или всплесковая:

  • округление вверх на ровной процессорной нагрузке стоит троттлинга,

  • а на всплесковой возвращает втрое более короткий хвост.

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

Обновлять один тулчейн и считать, что поведение включилось вместе с ним, не стоит вообще: решает строка go в go.mod.

А сколько у вас сервисов, где automaxprocs всё ещё лежит в go.mod?

В микросервисах важно не только находить проблемы, но и не закладывать их на этапе проектирования. На бесплатном уроке разберём, как проектировать бизнес-логику, определять границы ответственности сервисов и принимать архитектурные решения, которые упрощают развитие системы.

  • 22 октября в 19:00. «Основы проектирования бизнес-логики в микросервисной архитектуре». Записаться

А полный список вебинаров октября собрали в дайджесте.

Комментарии (0)