Модуль: 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 / period

cgroup 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 (дефолт)automaxprocsGo 1.25+
ИсточникNumCPU хостаcgroup quotacgroup quota
Учёт лимитанетда (floor)да (round, динамически)
Обновление в рантайменетнетда (~1/сек)
Зависимостьвнешняявстроено

Как тюнить#

  1. На Go 1.25+ — обычно ничего, дефолт уже корректен; убедитесь, что не задана лишняя env GOMAXPROCS.
  2. На Go <1.25 в контейнере — automaxprocs или явная установка под лимит.
  3. Latency-критичные сервисы: иногда выгодно установить GOMAXPROCS чуть выше CPU-limit (если есть burst-квота) или, наоборот, прибить к requests, чтобы избежать throttling.
  4. Замеряйте: 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.