golang

Go 1.25: GOMAXPROCS учитывает CPU-лимиты в контейнерах

  • четверг, 18 декабря 2025 г. в 00:00:07
https://habr.com/ru/companies/otus/articles/977648/
TL;DR
  • GOMAXPROCS ограничивает число потоков, которые одновременно исполняют Go-код (и тем самым задаёт параллелизм выполнения горутин); раньше по умолчанию он равнялся числу логических CPU на хосте.

  • В контейнерах с CPU-лимитами это давало рассинхронизацию: Go распараллеливался «по ноде», а Linux удерживал процесс троттлингом cgroups, ухудшая задержки.

  • В Go 1.25 дефолтный GOMAXPROCS учитывает CPU-лимит контейнера (если он меньше CPU хоста) и периодически обновляется при изменении лимита — если GOMAXPROCS не задан явно.

  • CPU-лимит — это квота CPU-времени за период, а GOMAXPROCS — лимит параллелизма; модели близкие, но не одинаковые.

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

  • CPU request не используется для выбора дефолта: без лимита контейнер может забирать свободные ресурсы ноды сверх запроса.

В Go 1.25 появились новые значения GOMAXPROCS по умолчанию с учётом контейнерного окружения. Это даёт более разумное поведение «из коробки» для многих контейнерных нагрузок, помогает избегать троттлинга, который может ухудшать задержки «в хвосте» (tail latency), и делает Go более готовым к продакшену без дополнительных настроек. В этой статье мы разберём, как Go планирует выполнение горутин, как это планирование взаимодействует с ограничениями CPU на уровне контейнера и как Go может работать лучше, учитывая такие ограничения.

GOMAXPROCS

Одна из сильных сторон Go — встроенная и простая в использовании конкурентность через горутины. С точки зрения семантики горутины очень похожи на потоки операционной системы и позволяют писать простой блокирующий код. При этом горутины намного легче, чем потоки ОС, поэтому создавать и уничтожать их «на лету» существенно дешевле.

Хотя реализация Go могла бы сопоставлять каждой горутине выделенный поток операционной системы, Go сохраняет горутины лёгкими за счёт планировщика в рантайме, который делает потоки взаимозаменяемыми. Любой поток под управлением Go может выполнить любую горутину, поэтому создание новой горутины не требует создания нового потока, а «пробуждение» горутины не обязательно означает пробуждение другого потока.

Однако вместе с планировщиком появляются вопросы планирования. Например, сколько именно потоков нужно использовать для выполнения горутин? Если готово к выполнению 1000 горутин, стоит ли планировать их на 1000 разных потоков?

Здесь и появляется GOMAXPROCS. На уровне смысла GOMAXPROCS сообщает рантайму Go, какой «доступный параллелизм» ему следует использовать. Если говорить более конкретно, GOMAXPROCS — это максимальное число потоков, которые могут одновременно выполнять горутины.

Так, если GOMAXPROCS=8 и есть 1000 горутин, готовых к выполнению, Go будет использовать 8 потоков и выполнять 8 горутин одновременно. Часто горутина работает совсем недолго и затем блокируется — и в этот момент Go переключается на выполнение другой горутины на том же потоке. Go также может принудительно прерывать (preempt) горутины, которые сами по себе не блокируются, чтобы у всех горутин была возможность выполниться.

Начиная с Go 1.5 и до Go 1.24 значение GOMAXPROCS по умолчанию было равно общему числу ядер CPU на машине. Обратите внимание: в этой статье под «ядром» более точно понимается «логический CPU». Например, машина с 4 физическими CPU и Hyper-Threading (гиперпоточность) имеет 8 логических CPU.

Обычно это хороший дефолт для «доступного параллелизма», потому что он естественным образом соответствует параллелизму железа. То есть если есть 8 ядер и Go запускает одновременно больше 8 потоков, операционной системе придётся мультиплексировать эти потоки на 8 ядрах — примерно так же, как Go мультиплексирует горутины на потоках. Этот дополнительный слой планирования не всегда создаёт проблемы, но это лишние накладные расходы.

Оркестрация контейнеров

Ещё одна ключевая сильная сторона Go — удобство развёртывания приложений в контейнерах, и управление тем, сколько ядер использует Go, становится особенно важным при запуске приложения в платформе оркестрации контейнеров. Платформы оркестрации вроде Kubernetes берут набор ресурсов машины и размещают контейнеры в пределах доступных ресурсов на основе запрошенных ресурсов. Чтобы упаковать как можно больше контейнеров в ресурсы кластера, платформа должна уметь предсказывать потребление ресурсов каждым запланированным контейнером. Мы хотим, чтобы Go соблюдал ограничения на использование ресурсов, которые задаёт платформа оркестрации контейнеров.

Рассмотрим влияние настройки GOMAXPROCS на примере Kubernetes. Платформы вроде Kubernetes предоставляют механизм ограничения ресурсов, потребляемых контейнером. В Kubernetes есть понятие лимитов ресурсов CPU, которые сообщают базовой операционной системе, сколько «ядерного» ресурса будет выделено конкретному контейнеру или набору контейнеров. Установка CPU-лимита приводит к созданию ограничения пропускной способности CPU в Linux control groups (cgroups).

До версии 1.25 Go не учитывал CPU-лимиты, заданные платформами оркестрации. Вместо этого он устанавливал GOMAXPROCS равным числу ядер на машине, где был запущен. Если при этом был задан CPU-лимит, приложение могло пытаться использовать CPU значительно больше, чем разрешено лимитом. Чтобы не дать приложению превысить ограничение, ядро Linux начинало троттлить приложение.

Троттлинг — грубый механизм ограничения контейнеров, которые иначе превысили бы свой CPU-лимит: он полностью останавливает выполнение приложения на оставшийся период троттлинга. Обычно этот период составляет 100 мс, поэтому троттлинг может заметно ухудшать задержки «в хвосте» по сравнению с более «мягкими» эффектами планирования и мультиплексирования при меньшем значении GOMAXPROCS. Даже если у приложения нет выраженного параллелизма, задачи, выполняемые рантаймом Go — например сборка мусора, — всё равно могут вызывать всплески нагрузки на CPU, которые и запускают троттлинг.

Новое значение по умолчанию

Мы хотим, чтобы Go по возможности предлагал эффективные и надёжные значения по умолчанию, поэтому в Go 1.25 мы сделали так, чтобы GOMAXPROCS по умолчанию учитывал контейнерное окружение. Если процесс Go работает внутри контейнера с CPU-лимитом, GOMAXPROCS по умолчанию будет равен этому CPU-лимиту, если он меньше числа ядер.

Системы оркестрации контейнеров могут изменять CPU-лимиты контейнера на лету, поэтому Go 1.25 также будет периодически проверять CPU-лимит и автоматически корректировать GOMAXPROCS, если лимит изменился.

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

Немного разные модели

И GOMAXPROCS, и CPU-лимит контейнера ограничивают максимальный объём CPU, который может использовать процесс, но их модели немного различаются.

GOMAXPROCS — это ограничение параллелизма. Если GOMAXPROCS=8, Go никогда не будет выполнять более 8 горутин одновременно.

CPU-лимиты, напротив, — это ограничение пропускной способности. То есть они ограничивают суммарное CPU-время, использованное за некоторый период реального (астрономического) времени. Период по умолчанию — 100 мс. Поэтому «лимит 8 CPU» на деле означает ограничение в 800 мс CPU-времени на каждые 100 мс реального времени.

Этот лимит можно «выбрать», непрерывно выполняя 8 потоков все 100 мс — что эквивалентно GOMAXPROCS=8. Но его можно «выбрать» и иначе: например, запустить 16 потоков по 50 мс каждый, при этом оставшиеся 50 мс каждый поток будет простаивать или блокироваться.

Другими словами, CPU-лимит не ограничивает общее число CPU, на которых контейнер может выполняться. Он ограничивает только суммарное CPU-время.

У большинства приложений потребление CPU достаточно равномерно распределено по периодам в 100 мс, поэтому новый дефолт GOMAXPROCS довольно хорошо соответствует CPU-лимиту — и уж точно лучше, чем ориентироваться на общее число ядер на машине! Однако стоит отметить: для особенно «спайковых» нагрузок эта смена может привести к росту задержек, поскольку GOMAXPROCS будет препятствовать кратковременным всплескам числа дополнительных потоков сверх среднего уровня, заданного CPU-лимитом.

Кроме того, поскольку CPU-лимиты — это ограничение пропускной способности, они могут быть дробными (например, 2.5 CPU). В то же время GOMAXPROCS обязан быть положительным целым числом. Поэтому Go должен округлить лимит до допустимого значения GOMAXPROCS. Go всегда округляет значение вверх, чтобы можно было использовать CPU-лимит полностью.

Запросы CPU 

Новый дефолт GOMAXPROCS в Go основан на CPU-лимите контейнера, но системы оркестрации контейнеров также предоставляют механизм «CPU-запрос» (CPU request). Если CPU-лимит задаёт максимальный объём CPU, который контейнер может использовать, то CPU запрос задаёт минимальный объём CPU, который гарантированно будет доступен контейнеру всегда.

Часто контейнеры создают с CPU-запросом, но без CPU-лимита — это позволяет контейнерам использовать ресурсы CPU машины сверх CPU-запроса, которые иначе простаивали бы из-за отсутствия нагрузки у других контейнеров. К сожалению, это означает, что Go не может выставлять GOMAXPROCS на основе CPU-запроса, поскольку это помешало бы использовать дополнительные простаивающие ресурсы.

Контейнеры с CPU-запросом всё равно ограничиваются при превышении запроса, если машина занята. Весовое ограничение при превышении запроса «мягче», чем жёсткий, привязанный к периодам троттлинг CPU-лимитов, но всплески нагрузки на CPU из-за высокого GOMAXPROCS всё равно могут негативно сказываться на поведении приложения.

Следует ли установить ограничение на использование процессора?

Мы разобрались, какие проблемы вызывает слишком высокий GOMAXPROCS, и что установка CPU-лимита контейнера позволяет Go автоматически выбрать подходящий GOMAXPROCS, поэтому логичный следующий вопрос — стоит ли всем контейнерам задавать CPU-лимит.

Хотя это может быть хорошим советом, чтобы автоматически получить разумные значения GOMAXPROCS по умолчанию, при решении о CPU-лимите нужно учитывать и другие факторы — например, что важнее: утилизация простаивающих ресурсов (тогда лимиты лучше не ставить) или предсказуемые задержки (тогда лимиты полезны).

Самое плохое поведение из-за рассинхронизации между GOMAXPROCS и фактическими CPU-ограничениями возникает, когда GOMAXPROCS значительно выше эффективного CPU-лимита. Например, небольшой контейнер с выделенными двумя CPU, запущенный на машине со 128 ядрами. Именно в таких случаях особенно важно подумать о явной установке CPU-лимита или, как вариант, о явной установке GOMAXPROCS.

Заключение

Go 1.25 предлагает более разумное поведение по умолчанию для многих контейнерных нагрузок, выставляя GOMAXPROCS на основе CPU-лимитов контейнера. Это помогает избегать троттлинга, который может ухудшать задержки «в хвосте», повышает эффективность и в целом старается сделать Go готовым к продакшену «из коробки». Получить новые дефолты можно просто указав версию Go 1.25.0 или выше в вашем go.mod.


Если вы работаете с Go в продакшене или только планируете переход, полезно разбираться не только в синтаксисе, но и во внутренней механике рантайма, планировании, работе с нагрузкой и микросервисами. Эти темы последовательно разбираются на курсе Golang Developer. Professional — с упором на идиоматику Go и реальные backend-сценарии.

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

А если хотите понять формат обучения — записывайтесь на бесплатные демо-уроки от преподавателей курса:

  • 23 декабря: «Пишем HTTP-сервер на Go». Записаться

  • 15 января: «Что ожидают компании от go-разработчиков в 2026». Записаться

  • 21 января: «Мониторинг: как понять, что твой сервис болен». Записаться