The Jerusalem PostTurkey sending technical, defensive support to Saudi Arabia to help fight Houthis, officials sayESPNAre Texans and Chargers this bad? How should we bet the Rams?ESPN DeportesGurú de las Diagonales: El misterio en torno a Drake MayeBollywood HungamaSCOOP: Ramayana expected to have paid previews on November 4; Godzilla Minus Zero to get limited showcasing in IMAX in IndiaDaily MaverickLABOUR ABUSE: New report exposes the exploitation behind the world’s food systems workersInquirerMan nabbed after surrendering unlicensed firearm in Oriental MindoroZDF heuteEntdecken Sie das ZDF-NachrichtenstudioThe South AfricanSaleng spotted back in Sundowns training ahead of Pirates showdownBBC Sport'Draining' few days for Gauff after online racist abuse01netAmazon éclate le prix de ce PC portable Dell : -45% pour finir le Prime Day en beautéRai NewsCile, cane randagio salvato dalla piena del fiume MapochoSBS 뉴스"우리 대응에 북한 당황"…"지뢰 제거, 단호한 대응이냐"
The Daily Newsstand · Free, Always
Wednesday, October 7, 2026

Go 1.25 сам читает лимит CPU, а automaxprocs молча это выключает

Translate

В 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. «Основы проектирования бизнес-логики в микросервисной архитектуре». Записаться

View the original on Хабр →

KioskNews shows a cleaned-up reading view extracted from the publisher’s page — the original always lives on their site, not ours.