Планировщик GMP и цена GOMAXPROCS

Перед началом: go-basic m6 (горутины, каналы, мьютексы) или эквивалентный опыт; уметь запускать go run и читать pprof — не требуется. Инструменты: Go 1.22+, docker (для воспроизведения тротлинга). Трудоёмкость: ~4 ч (теория 2 ч · практика 1.5 ч · квиз 0.5 ч).

🔥 Инцидент: сервис в контейнере отвечает вдесятеро медленнее

Сервис перенесли в Kubernetes, дали ему CPU-limit 2 ядра — и p99 вырос с 40 мс до 400. Код не менялся, запросы те же, CPU-профиль показывает «ничего не жрёт»: суммарная загрузка контейнера — 25 % от лимита. Но в cpu.stat контейнера растёт nr_throttled, а в логах сервиса — сотни GOMAXPROCS=64.

Причина: Go по умолчанию ставит GOMAXPROCS равным числу ядер хоста, а не квоты контейнера. 64 слота планировщика борются за 2 ядра: горутины готовы бежать, но cgroup душит их квотой раз в 100 мс — каждый период наступает пауза, и все запросы, попавшие в неё, ждут. Чем больше параллельных CPU-bound горутин, тем чаще тротлинг; потолок «в 25 %» здесь не экономия, а симптом: 75 % квоты сгорает на переключения и ожидание.

Этот модуль — про то, кто такие G, M и P, зачем их ровно GOMAXPROCS и почему в контейнере эту цифру нельзя оставлять на волю случая.

📖 Теория

1.1 GMP: три сущности и одна очередь

Горутина (G) — единица работы: функция, стек 2–8 КБ, создаётся за микросекунды. Поток ОС (M) — то, что реально исполняет код на ядре. Слот (P) — право исполнять горутины: у каждого P своя локальная очередь готовых G, и таких слотов ровно GOMAXPROCS.

Планировщик GMP

Три факта, которые объясняют 90 % поведения планировщика:

  • Горутин может быть миллион, P — единицы. Готовые G ждут в локальных очередях P (до 256) и в глобальной; освободившийся P берёт следующую.
  • Блокирующий вызов не съедает P. Когда горутина уходит в ожидание сети или канала, M отпускает P — тот сразу достаётся другому потоку. Поэтому «ждущая» горутина почти бесплатна.
  • Пустой P ворует работу. Work stealing: если локальная очередь P пуста, он забирает половину у соседа — так тысячи горутин распределяются по доступным слотам.

1.2 GOMAXPROCS: потолок параллелизма, а не «число потоков»

GOMAXPROCS — сколько горутин могут исполняться одновременно (в состоянии бега на CPU). Это не лимит потоков ОС: на блокирующих вызовах потоков может быть больше — планировщик создаёт/поднимает M по необходимости. И это не лимит горутин — их сколько угодно.

Практическое следствие: значение имеет смысл только для CPU-bound нагрузки. Для сервиса, где горутины в основном спят на сети и БД, завышенный GOMAXPROCS почти безвреден — но и не помогает; а вот CPU-bound-код (шифрование, сериализация, сжатие) он ускоряет линейно до числа реально доступных ядер.

Проверить, что происходит внутри, можно не выходя из процесса:

go
fmt.Println(runtime.NumCPU(), runtime.GOMAXPROCS(0))
// GODEBUG=schedtrace=1000 go run main.go — раз в секунду печатает
// состояние планировщика: длины очередей, число M, steals.

1.3 Ловушка контейнеров: NumCPU ≠ ваша квота

В cgroup v2 квота CPU живёт в cpu.max: например 200000 100000 — 200 мс на период 100 мс, то есть 2 ядра. runtime.NumCPU() в контейнере при этом видит ядра хоста (64) — Go не читает cgroup сам.

Итог: GOMAXPROCS=64 при квоте 2. Планировщик честно запускает до 64 горутин «на CPU», ядро исполняет 2, cgroup копит долг и в конце периода замораживает контейнер на остаток квоты. Это и есть nr_throttled/throttled_usec в cpu.stat — главная метрика этой болезни.

Лечение — либо явно, либо автоматически:

go
// mini-automaxprocs: читаем квоту cgroup v2 и ставим честный потолок.
func setupMaxProcs() {
    raw, err := os.ReadFile("/sys/fs/cgroup/cpu.max")
    if err != nil {
        return // не cgroup v2 — оставляем дефолт
    }
    // формат: "<quota> <period>" или "max <period>"
    fields := strings.Fields(string(raw))
    if len(fields) != 2 || fields[0] == "max" {
        return
    }
    quota, _ := strconv.Atoi(fields[0])
    period, _ := strconv.Atoi(fields[1])
    if period <= 0 || quota <= 0 {
        return
    }
    limit := (quota + period - 1) / period // ceil, как в automaxprocs
    if limit < 1 {
        limit = 1
    }
    runtime.GOMAXPROCS(limit)
}

В бою это делает go.uber.org/automaxprocs (подключается одной строкой в main, читает и cgroup v1/v2, и quota/period). Ручной GOMAXPROCS=N в манифесте — тоже вариант, но легко разъезжается с лимитом при следующем ресайзе.

1.4 Как увидеть проблему своими глазами

Три инструмента, которых хватает:

ИнструментЧто показывает
cpu.stat: nr_throttled, throttled_usecконтейнер душат квотой
GODEBUG=schedtrace=1000длины очередей, steals, число M и P
pprof goroutineсколько G и в каком состоянии висят

Правило чтения: растущий nr_throttled при недогруженном CPU — почти всегда GOMAXPROCS больше квоты (или CPU-bound-пул больше, чем нужно). Это не «мало ядер» — это слишком много претендентов.

1.5 Компромиссы

  • Автоматика vs ручной GOMAXPROCS: automaxprocs сам подстроится под ресайз контейнера, но добавляет зависимость и скрывает цифру; ручное значение видно в манифесте, но разъезжается с лимитом.
  • GOMAXPROCS = квоте vs меньше квоты: равенство даёт максимум CPU-bound-пропускной способности; занижение (например, quota−1) оставляет запас на шум и уменьшает тротлинг ценой параллелизма.
  • Один пул на всё vs разные лимиты для CPU- и IO-работы: общий GOMAXPROCS проще, но CPU-bound-пул на 64 воркера при квоте 2 всё равно упрётся в тротлинг — лимит воркеров задаётся отдельно от GOMAXPROCS.

💻 Практика

  1. Воспроизведите тротлинг. Контейнер с --cpus=2, внутри — CPU-bound нагрузка (например, sha256 в 200 горутинах) и GOMAXPROCS=64. Снимите nr_throttled из cpu.stat за 10 секунд, затем повторите с GOMAXPROCS=2. Сравните пропускную способность и throttled-время; объясните, почему второй прогон может оказаться быстрее, хотя «слотов меньше».
  2. Мини-automaxprocs. Допишите setupMaxProcs из §1.3 до рабочего вида (cgroup v2 cpu.max, обработка max), добавьте юнит-тест на парсинг (табличные кейсы: 200000 100000 → 2; max 100000 → без изменений; 150000 100000 → 2). Запустите в контейнере с лимитом 1.5 и покажите, что GOMAXPROCS стал 2.
  3. Счётчики планировщика. Снимите GODEBUG=schedtrace=200 с сервиса под нагрузкой: найдите в выводе steals, runqueue и объясните, куда делись горутины, если очереди пусты, а latency высокая.
  4. Запустите сниппет модуля в песочнице: он показывает NumCPU, число P, стоимость 1000 горутин и кооперативную уступку через runtime.Gosched.

🧩 Quiz

1. Горутина ушла в блокирующий сетевой вызов. Что происходит с её P?
2. Где в cgroup v2 посмотреть CPU-квоту контейнера?
3. В контейнере с CPU-limit 2 GOMAXPROCS остался 64. Что произойдёт с CPU-bound нагрузкой?
4. Чем горутина дешевле потока ОС?
5. Что такое P в модели планировщика Go?

✅ Итоги модуля

Студент должен:

  • Объяснить роли G, M и P и зачем P отдельная сущность
  • Назвать, что именно ограничивает GOMAXPROCS, а что — нет
  • Диагностировать тротлинг контейнера по cpu.stat и schedtrace
  • Понимать, почему runtime.NumCPU() в контейнере врёт
  • Реализовать mini-automaxprocs с парсингом cpu.max
  • Объяснить work stealing и освобождение P на блокирующем вызове
  • Ответить на все вопросы Quiz

🔗 Мостик к Модулю 2

Планировщик объясняет, когда горутины получают процессор. Но не объясняет, что они видят: какая запись в память станет видна другой горутине и в каком порядке. Дальше — модель памяти Go: happens-before, data race и почему «флаг готовности» без синхронизации — лотерея.