Модуль: Runtime и память · Уровень: Senior+
TL;DR#
GOMAXPROCS задаёт число P (logical processors) в планировщике Go — максимальное количество горутин, выполняющихся на CPU параллельно. Исторически дефолт равнялся числа CPU хоста (runtime.NumCPU()), что в контейнерах с cgroup-лимитом приводило к oversubscription: Go видит, скажем, 64 ядра хоста, а квота — 2 ядра, отсюда лишние context switches, раздутый GC и хвостовая латентность. Решали это библиотекой automaxprocs от Uber; в Go 1.25 runtime стал cgroup-aware и читает CPU-квоту автоматически, периодически её обновляя.
Теория#
P, M, G — где здесь GOMAXPROCS#
Планировщик Go строится на трёх сущностях:
- G (goroutine) — горутина.
- M (machine) — OS-тред.
- P (processor) — логический процессор, контекст для выполнения; владеет локальной очередью runnable-горутин.
GOMAXPROCS == число P. Чтобы M выполнял Go-код, он должен держать P. Значит не более GOMAXPROCS горутин выполняют Go-код на CPU одновременно. Это и есть граница реального параллелизма.
G G G G (горутины)
\ \ / /
P P GOMAXPROCS = 2 -> максимум 2 параллельно
| |
M M (OS-треды, их может быть больше при syscalls)Параллелизм vs конкурентность#
- Конкурентность — структура программы: много горутин, логически независимых. Их можно иметь миллионы при
GOMAXPROCS=1. - Параллелизм — физическое одновременное исполнение. Ограничено
GOMAXPROCSи числом физических ядер.
GOMAXPROCS=1 не мешает писать конкурентный код — он лишь сериализует фактическое исполнение Go-кода (горутины кооперативно вытесняются). Это «знаменитый» тезис Роба Пайка: concurrency is not parallelism.
Управление значением#
import "runtime"
n := runtime.GOMAXPROCS(0) // 0 = прочитать текущее, не меняя
old := runtime.GOMAXPROCS(4) // установить 4, вернёт прежнее значениеGOMAXPROCS=4 ./app # через переменную окружения (имеет приоритет)Приоритет: env GOMAXPROCS (если задана и валидна) → программный вызов → дефолт runtime.
Почему важно для GC и латентности#
- GC использует worker-горутины пропорционально
GOMAXPROCS(≈25% от P на dedicated mark workers). Завышенный GOMAXPROCS в контейнере => больше GC-воркеров, чем выделено ядер => они конкурируют за квоту, GC-паузы и assist-время растут. - Хвостовая латентность (p99): при oversubscription планировщик ОС вынужден дёргать множество M на малом числе ядер => лишние context switches, потеря L1/L2-кэша, throttling по cgroup-квоте (контейнер «замораживается» до конца периода). Это бьёт именно по хвосту.
Проблема контейнеров (суть)#
До Go 1.25 дефолт был runtime.NumCPU(), который возвращал число онлайн-CPU хоста, игнорируя cgroup CPU-квоту. Типичная картина в Kubernetes:
resources:
limits:
cpu: "2" # cgroup quota: 200000 / 100000 = 2 CPU
# но узел имеет 64 ядраGo при старте видел 64 => GOMAXPROCS=64. Последствия:
- 64 потенциально активных P конкурируют за эффективные 2 ядра квоты.
- При исчерпании квоты в периоде CFS контейнер throttling — все треды замораживаются до следующего периода (100мс), что даёт скачки латентности.
- GC-воркеров слишком много, они усиливают throttling.
- Лишние переключения контекста, размывание кэша.
cgroups: где живёт лимит#
cgroup v1 (CPU controller):
# Период и квота (в микросекундах):
cat /sys/fs/cgroup/cpu/cpu.cfs_period_us # обычно 100000 (100мс)
cat /sys/fs/cgroup/cpu/cpu.cfs_quota_us # например 200000 => 2 CPU
# Эффективный лимит = quota / periodcgroup v2 (унифицированная иерархия):
cat /sys/fs/cgroup/cpu.max # "200000 100000" => quota period => 2 CPU
# "max 100000" => без лимитаРасчёт эффективного числа CPU: ceil(quota / period) (округление вверх). Квота 200000/100000 = 2.0 CPU. Дробная квота 150000/100000 = 1.5 округляется до 2.
Важно: CPU requests (cgroup cpu.shares / cpu.weight) — это только вес при контеншене, НЕ жёсткий лимит, его для GOMAXPROCS не используют. Используется именно limit (quota).
Решение до 1.25: uber-go/automaxprocs#
import _ "go.uber.org/automaxprocs" // init() выставит GOMAXPROCS из cgroup-квотыВ init() библиотека читает cpu.cfs_quota_us/cpu.max, вычисляет floor(quota/period) (минимум 1) и вызывает runtime.GOMAXPROCS. Минусы: статично (читает один раз при старте), не реагирует на vertical scaling/изменение лимита в рантайме, использует floor (а не ceil), и это внешняя зависимость.
# Контроль из лога automaxprocs:
# "maxprocs: Updating GOMAXPROCS=2: determined from CPU quota"Go 1.25: контейнер-aware runtime#
Начиная с Go 1.25 рантайм сам учитывает cgroup CPU-лимит при определении дефолтного GOMAXPROCS:
- При старте дефолт =
max(минимум(round(cgroup CPU limit), NumCPU), 2)-подобная логика: берётся минимум из числа CPU машины и CPU-лимита cgroup (округление вверх), но не меньше 1 (на практике учитывается дробная квота). - Рантайм периодически (примерно раз в секунду) перечитывает лимит и обновляет GOMAXPROCS «на лету» при vertical scaling/изменении квоты — чего automaxprocs не умел.
- Явно заданная env
GOMAXPROCSили вызовruntime.GOMAXPROCS()отключают автоматику (ваше значение имеет приоритет и фиксируется). - Поведение можно регулировать через
GODEBUG:containermaxprocs=0отключает учёт cgroup-лимита,updatemaxprocs=0отключает периодическое обновление (для возврата к старому поведению/совместимости).
Это делает automaxprocs ненужным на Go 1.25+ для большинства случаев.
| до Go 1.25 (дефолт) | automaxprocs | Go 1.25+ | |
|---|---|---|---|
| Источник | NumCPU хоста | cgroup quota | cgroup quota |
| Учёт лимита | нет | да (floor) | да (round, динамически) |
| Обновление в рантайме | нет | нет | да (~1/сек) |
| Зависимость | — | внешняя | встроено |
Как тюнить#
- На Go 1.25+ — обычно ничего, дефолт уже корректен; убедитесь, что не задана лишняя env
GOMAXPROCS. - На Go <1.25 в контейнере —
automaxprocsили явная установка под лимит. - Latency-критичные сервисы: иногда выгодно установить GOMAXPROCS чуть выше CPU-limit (если есть burst-квота) или, наоборот, прибить к requests, чтобы избежать throttling.
- Замеряйте: cgroup throttling-метрики (
nr_throttled,throttled_timeизcpu.stat), p99 latency, GC-метрики до/после изменения.
# Сколько раз нас тормозил CFS (cgroup v2):
cat /sys/fs/cgroup/cpu.stat
# nr_throttled, throttled_usec — рост = надо снижать GOMAXPROCS или поднимать limitПодводные камни / gotchas#
- CPU requests != limits. GOMAXPROCS привязывается к limit (quota), а не к requests (shares). Если лимит не задан вовсе — в Go 1.25 поведение как раньше (NumCPU).
- Дробная квота. Лимит
cpu: "1500m"(1.5 CPU): automaxprocs возьмёт floor=1, Go 1.25 округлит иначе. Понимать, какое значение реально применилось (логироватьruntime.GOMAXPROCS(0)при старте). - Env
GOMAXPROCSотключает автоматику Go 1.25. Случайно унаследованная переменная в манифесте сводит на нет container-awareness. - Throttling маскируется под «медленный код». p99-скачки кратные 100мс (период CFS) — почти всегда throttling, а не GC и не ваш код.
- CGO/блокирующие syscalls плодят M, не P. Большое число тредов в
threadcreate-профиле при малом GOMAXPROCS — это про syscalls, а не про параллелизм Go-кода. GOMAXPROCS=1не делает код «однопоточным» полностью — рантайм всё равно создаёт служебные M (sysmon, GC, syscalls).- Динамическое обновление в 1.25 может удивить, если вы делали capacity-расчёты по фиксированному GOMAXPROCS; при vertical pod autoscaling значение меняется в рантайме.
Вопросы на собеседовании#
В: Что физически означает GOMAXPROCS и как он связан с моделью P-M-G?
О: Это число P — логических процессоров планировщика. M (OS-тред) обязан владеть P, чтобы исполнять Go-код, поэтому одновременно на CPU выполняется не более GOMAXPROCS горутин. Это верхняя граница реального параллелизма Go-кода; конкурентность (число горутин) при этом не ограничена.
В: Почему в Kubernetes старый Go-сервис деградировал по p99 при дефолтном GOMAXPROCS? О: До 1.25 дефолт = NumCPU хоста, игнорируя cgroup-лимит. На 64-ядерном узле с лимитом 2 CPU рантайм запускал 64 P и пропорционально много GC-воркеров, которые конкурировали за 2 ядра квоты. Это вызывало лишние context switches, размывание кэша и CFS-throttling (заморозка до конца 100мс-периода) — скачки именно в хвосте latency.
В: Чем cgroup v1 и v2 отличаются в части CPU-лимита?
О: В v1 лимит задаётся двумя файлами: cpu.cfs_quota_us и cpu.cfs_period_us, эффективный CPU = quota/period. В v2 это один файл cpu.max в формате "<quota> <period>", либо "max" при отсутствии лимита. Семантика та же — отношение квоты к периоду.
В: Что делает automaxprocs и какие у неё ограничения?
О: В init() читает cgroup CPU-квоту, вычисляет floor(quota/period) (минимум 1) и устанавливает GOMAXPROCS. Ограничения: читает один раз при старте (не реагирует на изменение лимита в рантайме), использует floor, и это внешняя зависимость. На Go 1.25+ обычно избыточна.
В: Что нового принёс Go 1.25 в этой области?
О: Рантайм стал cgroup-aware: дефолтный GOMAXPROCS вычисляется с учётом CPU-лимита cgroup (округление вверх, не меньше 1) и периодически (~раз в секунду) перечитывается и обновляется на лету. Управляется через GODEBUG containermaxprocs и updatemaxprocs. Явная env/вызов отключают автоматику.
В: Используется ли для GOMAXPROCS CPU requests или limits?
О: Limits (cgroup quota — жёсткий потолок). Requests маппятся в cpu.shares/cpu.weight — это лишь относительный вес при контеншене, не потолок, поэтому для определения параллелизма он не годится. Если limit не задан, runtime откатывается к числу CPU.
В: Как GOMAXPROCS влияет на сборщик мусора? О: GC mark-фаза использует dedicated worker-горутины числом ≈25% от GOMAXPROCS, плюс mutator assist. Завышенный GOMAXPROCS относительно реальной квоты => больше GC-воркеров на тех же ядрах => усиление throttling и assist, рост пауз/латентности. Поэтому корректный GOMAXPROCS важен и для GC, а не только для прикладного кода.
В: Как диагностировать CFS-throttling и подтвердить, что проблема в GOMAXPROCS?
О: Смотреть cpu.stat (nr_throttled, throttled_usec) — рост означает, что контейнер упирается в квоту. Сопоставить с GOMAXPROCS (логировать runtime.GOMAXPROCS(0) при старте) и числом ядер квоты. Скачки latency, кратные периоду CFS (100мс), — характерный признак. Снижение GOMAXPROCS до лимита (или Go 1.25) обычно убирает throttling.
В: Можно ли поставить GOMAXPROCS больше CPU-лимита и зачем? О: Иногда да: при наличии burst-квоты или IO-bound нагрузке небольшое превышение повышает throughput за счёт перекрытия ожиданий. Но риск — рост throttling и хвостовой латентности. Это всегда вопрос замеров под конкретный профиль нагрузки, а не дефолтная рекомендация.
На что копают на senior+#
- Точное понимание P-M-G и того, что GOMAXPROCS лимитирует именно исполнение Go-кода на CPU, а не число тредов (syscalls плодят M сверх GOMAXPROCS).
- Глубокое знание cgroup v1/v2, формулы
quota/period, разницы requests/limits и CFS-throttling как механизма (период, заморозка, метрикиcpu.stat). - Связь GOMAXPROCS ↔ GC (доля mark-воркеров, assist) и влияние на p99.
- Эволюция решения: ручная установка → automaxprocs (floor, статично) → Go 1.25 (round, динамическое обновление, GODEBUG-флаги) и понимание приоритетов (env vs вызов vs дефолт).
- Практика тюнинга под latency-SLO: когда привязывать к limit, когда к requests, как доказывать эффект изменения метриками, риски динамического обновления при VPA.