Планировщик 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.
Три факта, которые объясняют 90 % поведения планировщика:
- Горутин может быть миллион, P — единицы. Готовые G ждут в локальных очередях P (до 256) и в глобальной; освободившийся P берёт следующую.
- Блокирующий вызов не съедает P. Когда горутина уходит в ожидание сети или канала, M отпускает P — тот сразу достаётся другому потоку. Поэтому «ждущая» горутина почти бесплатна.
- Пустой P ворует работу. Work stealing: если локальная очередь P пуста, он забирает половину у соседа — так тысячи горутин распределяются по доступным слотам.
1.2 GOMAXPROCS: потолок параллелизма, а не «число потоков»
GOMAXPROCS — сколько горутин могут исполняться одновременно
(в состоянии бега на CPU). Это не лимит потоков ОС: на блокирующих
вызовах потоков может быть больше — планировщик создаёт/поднимает M
по необходимости. И это не лимит горутин — их сколько угодно.
Практическое следствие: значение имеет смысл только для CPU-bound нагрузки. Для сервиса, где горутины в основном спят на сети и БД, завышенный GOMAXPROCS почти безвреден — но и не помогает; а вот CPU-bound-код (шифрование, сериализация, сжатие) он ускоряет линейно до числа реально доступных ядер.
Проверить, что происходит внутри, можно не выходя из процесса:
gofmt.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.
💻 Практика
- Воспроизведите тротлинг. Контейнер с
--cpus=2, внутри — CPU-bound нагрузка (например,sha256в 200 горутинах) иGOMAXPROCS=64. Снимитеnr_throttledизcpu.statза 10 секунд, затем повторите сGOMAXPROCS=2. Сравните пропускную способность и throttled-время; объясните, почему второй прогон может оказаться быстрее, хотя «слотов меньше». - Мини-automaxprocs. Допишите
setupMaxProcsиз §1.3 до рабочего вида (cgroup v2cpu.max, обработкаmax), добавьте юнит-тест на парсинг (табличные кейсы:200000 100000→ 2;max 100000→ без изменений;150000 100000→ 2). Запустите в контейнере с лимитом 1.5 и покажите, что GOMAXPROCS стал 2. - Счётчики планировщика. Снимите
GODEBUG=schedtrace=200с сервиса под нагрузкой: найдите в выводеsteals,runqueueи объясните, куда делись горутины, если очереди пусты, а latency высокая. - Запустите сниппет модуля в песочнице: он показывает NumCPU,
число P, стоимость 1000 горутин и кооперативную уступку через
runtime.Gosched.
🧩 Quiz
✅ Итоги модуля
Студент должен:
- Объяснить роли G, M и P и зачем P отдельная сущность
- Назвать, что именно ограничивает GOMAXPROCS, а что — нет
- Диагностировать тротлинг контейнера по
cpu.statиschedtrace - Понимать, почему
runtime.NumCPU()в контейнере врёт - Реализовать mini-automaxprocs с парсингом
cpu.max - Объяснить work stealing и освобождение P на блокирующем вызове
- Ответить на все вопросы Quiz
🔗 Мостик к Модулю 2
Планировщик объясняет, когда горутины получают процессор. Но не объясняет, что они видят: какая запись в память станет видна другой горутине и в каком порядке. Дальше — модель памяти Go: happens-before, data race и почему «флаг готовности» без синхронизации — лотерея.