---
title: "Планировщик глубже"
description: "Два вопроса, которые рано или поздно задают на собеседовании или в инциденте. Почему сервис в контейнере с лимитом в 2 CPU показывает рваную задержку, хотя процессор загружен…"
canonical: https://graphlms.ru/go/book/scheduler-deep
course: "Конкурентность Go"
updated: 2026-10-12
lang: ru
publisher: GraphLMS
---

# Планировщик глубже

> Источник: https://graphlms.ru/go/book/scheduler-deep — курс «Конкурентность Go», GraphLMS.

## О чём глава

Два вопроса, которые рано или поздно задают на собеседовании или в инциденте. Почему
сервис в контейнере с лимитом в 2 CPU показывает рваную задержку, хотя процессор
загружен наполовину? Почему у процесса с `GOMAXPROCS=8` в списке потоков их больше
сотни? Ответы лежат в устройстве планировщика Go.

В [первой главе](/go/book/goroutines) мы ввели модель G-M-P: G — горутина, M — поток ОС,
P — разрешение выполнять Go-код со своей очередью готовых горутин. Здесь посмотрим, как
из этих трёх сущностей получается работающий планировщик: откуда P берёт следующую
горутину, как простаивающий P забирает работу у занятого, как рантайм отбирает процессор
у горутины, которая не хочет его отдавать, и откуда берутся «лишние» потоки.

## Перед чтением

Нужна модель G-M-P из [первой главы](/go/book/goroutines) и уверенное обращение с
каналами, мьютексами и контекстом. Это дополнительное чтение: задачи тренажёра решаются
без знания размеров внутренних очередей и эвристик рантайма. Но объяснить результаты
бенчмарка или поведение сервиса под нагрузкой без этой главы трудно.

Держите в голове три разные величины: сколько горутин живо (G), сколько потоков у
процесса (M) и сколько горутин могут выполняться одновременно (P). Большая часть главы —
о том, почему они не совпадают.

В браузерной песочнице из `runtime` есть `Gosched`, `GOMAXPROCS`, `NumCPU`,
`NumGoroutine`, `GC` и `ReadMemStats`. Чисел, зависящих от вашей машины (`NumCPU`,
`GOMAXPROCS`), и поведения на нескольких ядрах она не воспроизводит, это проверяйте
локально.

## GOMAXPROCS: сколько горутин выполняется одновременно

`GOMAXPROCS` — число P, то есть сколько горутин могут выполнять Go-код в один и тот же
момент. Горутин могут быть миллионы, но в каждый момент Go-код выполняют не больше
`GOMAXPROCS` из них. Остальные либо стоят в очереди готовых, либо ждут (канал, мьютекс,
таймер, сеть).

```go
n := runtime.GOMAXPROCS(0) // аргумент 0: только прочитать текущее значение
fmt.Println("логических CPU:", runtime.NumCPU(), "GOMAXPROCS:", n)
```

Значение по умолчанию зависит от версии Go, и в контейнерах эта разница заметна.

**До Go 1.25** по умолчанию `GOMAXPROCS` равнялся числу логических CPU машины. В
контейнере рантайм видел все ядра хоста, а не выделенный лимит. Машина на 64 ядра,
контейнеру выдано 2 CPU, а `GOMAXPROCS` всё равно 64. Рантайм запускал до 64 потоков с
Go-кодом, они за доли секунды выбирали квоту CPU, ядро ОС приостанавливало контейнер до
конца периода (это называется троттлингом), и задержка ответов скачками росла. Годами
это лечили библиотекой `go.uber.org/automaxprocs`, которая читает лимит из cgroup и
выставляет `GOMAXPROCS` по нему.

**С Go 1.25** на Linux рантайм сам учитывает CPU-лимит cgroup. По документации
`runtime.GOMAXPROCS`:

- значение по умолчанию — минимум из числа логических CPU, маски CPU affinity и лимита
  cgroup;
- дробный лимит округляется вверх: лимит 1.5 CPU даёт 2;
- рантайм не опускает значение ниже 2, если самих CPU не меньше двух: контейнер с
  лимитом 1 CPU получит `GOMAXPROCS=2`;
- рантайм проверяет лимит не чаще раза в секунду и меняет `GOMAXPROCS`, если лимит
  изменился;
- если задать `GOMAXPROCS` вручную (переменной окружения или вызовом функции),
  автоматическое обновление отключается.

Новое поведение включается, только если в `go.mod` указана версия `go 1.25` или новее;
для старых версий языка остаётся прежнее. Поэтому после обновления Go проверьте, что
версия в `go.mod` тоже обновлена, иначе контейнер будет работать по-старому.

## Очереди готовых горутин

Откуда P берёт следующую горутину?

У каждого P есть **локальная очередь** на 256 горутин. Когда горутина запускает новую
через `go f()`, новая горутина попадает в очередь того же P. Причём не в конец, а в
особый слот `runnext`: она будет выполнена следующей. Это ускоряет частый сценарий
«отправил значение в канал и разбудил получателя»: получатель запустится сразу, пока
данные ещё в кэше этого ядра. Работа с локальной очередью не требует общих блокировок.

Кроме локальных, есть одна **глобальная очередь** на весь процесс. Туда уходит половина
локальной очереди, когда та переполнилась, и туда же попадают горутины, ставшие готовыми
без привязки к конкретному P. Глобальная очередь защищена блокировкой, поэтому
обращаются к ней реже.

Чтобы горутины в глобальной очереди не ждали вечно, пока P занят своей локальной, P раз
в 61 цикл планирования заглядывает в глобальную очередь даже при непустой локальной.
Число 61 — деталь реализации, полагаться на него нельзя; важен сам принцип защиты от голодания.

## Work-stealing: как простаивающий P находит работу

Нагрузка между P почти никогда не равномерна: один разгрёб свою очередь, у другого их
сотня. Центрального диспетчера, который перекладывал бы горутины, в Go нет. Вместо него
простаивающий P сам ищет работу — это называют **work-stealing** («кража работы»).

Когда локальная очередь пуста, P перед тем, как уснуть, проверяет по очереди:

1. глобальную очередь;
2. сетевой поллер: нет ли горутин, для которых пришли данные из сети;
3. очереди других P, выбранных в случайном порядке, и забирает у первого непустого
   **половину** его горутин.

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

Посмотреть на это можно через переменную окружения `GODEBUG=schedtrace=500`: раз в 500 мс
рантайм печатает состояние планировщика. Программа запускает 1000 горутин, каждая полторы
секунды крутит цикл:

```text
$ GOMAXPROCS=4 GODEBUG=schedtrace=500 ./app
SCHED 0ms: gomaxprocs=4 idleprocs=1 threads=5 spinningthreads=1 needspinning=0 idlethreads=0 runqueue=0 [ 1 0 0 0 ] schedticks=[ 0 0 0 0 ]
SCHED 503ms: gomaxprocs=4 idleprocs=0 threads=5 spinningthreads=0 needspinning=1 idlethreads=0 runqueue=602 [ 107 175 107 5 ] schedticks=[ 23 24 23 22 ]
SCHED 1006ms: gomaxprocs=4 idleprocs=0 threads=5 spinningthreads=0 needspinning=1 idlethreads=0 runqueue=558 [ 86 154 86 112 ] schedticks=[ 44 45 44 43 ]
```

Как читать: `idleprocs=0` — все четыре P заняты; `threads=5` — потоков пять, хотя
горутин тысяча; `runqueue=602` — горутин в глобальной очереди; `[ 107 175 107 5 ]` —
длины локальных очередей четырёх P. Видно, что работа разложена неравномерно и
постоянно перетекает: у четвёртого P было 5 горутин, через полсекунды стало 112.
`schedticks` — сколько раз каждый P переключал горутины; к ним вернёмся в следующем
разделе.

## Вытеснение: как отобрать P у горутины, которая не отдаёт

До Go 1.14 планировщик был кооперативным: горутина отдавала P только в определённых
местах. Это блокирующие операции (канал, мьютекс, системный вызов), явный
`runtime.Gosched()` и проверка в начале вызова функции, которую компилятор вставлял для
роста стека. Цикл без вызовов функций таких мест не содержал:

```go
// До Go 1.14 мог занять P навсегда:
for {
	x++ // ни вызовов, ни каналов: нет места, где можно отдать P
}
```

При `GOMAXPROCS=1` такой цикл останавливал всё: другие горутины не получали процессор,
а сборщик мусора не мог начать работу, потому что ждал, пока все горутины дойдут до
безопасной точки.

С **Go 1.14** работает **асинхронное вытеснение**. Фоновый поток рантайма (sysmon, о нём
ниже) замечает горутину, которая выполняется на одном P дольше 10 мс, и посылает её
потоку сигнал (на Unix — `SIGURG`). Обработчик сигнала останавливает горутину в
безопасной точке и возвращает её в очередь. Теперь даже цикл без единого вызова функции
будет вытеснен.

В выводе `schedtrace` выше это видно по `schedticks`: за каждые 500 мс каждый P
переключается примерно 21 раз, то есть одна горутина держит P порядка 20 мс подряд. Наши
горутины ничего не ждут и сами P не отдают; все эти переключения — вытеснение.

Значит ли это, что о длинных циклах можно не думать? Нет. Вытеснение защищает от
полной остановки программы, но не делает код отменяемым: горутина продолжит считать после
вытеснения, просто по очереди с другими. Долгую вычислительную работу всё равно режут на
части и между частями проверяют [`ctx.Done()`](/go/book/context).

`runtime.Gosched()` — явная уступка: горутина отдаёт P и встаёт в глобальную очередь
готовых. С одним P очередь видна в выводе:

```go play
package main

import (
	"fmt"
	"runtime"
	"sync"
)

func main() {
	runtime.GOMAXPROCS(1) // один P, чтобы чередование было видно
	var wg sync.WaitGroup
	for id := 1; id <= 3; id++ {
		wg.Add(1)
		go func(id int) {
			defer wg.Done()
			for step := 0; step < 2; step++ {
				fmt.Printf("горутина %d, шаг %d\n", id, step)
				runtime.Gosched() // уступаем P другим
			}
		}(id)
	}
	wg.Wait()
	fmt.Println("все горутины завершились")
}
```

С одним P горутины, скорее всего, будут чередоваться: шаг 0 у каждой, потом шаг 1 у
каждой. На Go 1.25 первой обычно печатает горутина 3: последняя запущенная горутина
попадает в слот `runnext` и выполняется раньше остальных. Но это лишь наблюдение: порядок выбора следующей горутины не входит в
спецификацию. Уберите `GOMAXPROCS(1)`, и на многоядерной машине горутины пойдут
параллельно, а строки перемешаются произвольно. В обычном коде `Gosched` не нужен.

## Системные вызовы и handoff: откуда лишние потоки

Почему потоков бывает намного больше, чем `GOMAXPROCS`?

Когда горутина делает **блокирующий системный вызов** (чтение файла с медленного диска,
вызов C-функции через cgo), её поток M застревает в ядре ОС до возврата из вызова. Если
бы P оставался у этого M, он простаивал бы всё это время.

Поэтому рантайм делает **handoff** (передачу): если вызов не вернулся быстро, P отбирают у застрявшего M и отдают другому потоку. Свободный поток берётся из
запаса или создаётся новый. Когда системный вызов вернётся, горутина попытается получить
свободный P; если его нет, она встанет в глобальную очередь, а поток уснёт про запас.

Вывод: число потоков ОС определяется тем, сколько горутин одновременно сидят в
блокирующих системных вызовах, а не `GOMAXPROCS`. Сто горутин, одновременно читающих
файлы с медленного диска, могут дать сотню потоков. Верхний предел задаёт
`runtime/debug.SetMaxThreads` (по умолчанию 10 000); если программа в него упирается, она
аварийно завершается, и это почти всегда признак ошибки.

**Сеть** устроена по-другому. Сетевые сокеты в Go неблокирующие, ими занимается **сетевой
поллер** — механизм ОС, который сообщает, какие из тысяч сокетов готовы к чтению или
записи (epoll в Linux, kqueue в macOS и BSD, IOCP в Windows). Горутина, ждущая данных из
сокета, паркуется и не занимает поток. Когда данные приходят, поллер делает её снова
готовой. Поэтому десять тысяч соединений — это десять тысяч горутин, но не десять тысяч
потоков.

## Спин перед сном

Поток, у P которого кончилась работа, засыпает не сразу. Сначала он короткое время
**крутится** (spinning) в поиске работы: проверяет очереди, пробует украсть. Усыпить поток
и потом разбудить его — системные вызовы, которые стоят заметно дороже короткого
ожидания, а под нагрузкой работа обычно появляется через мгновение. В `schedtrace` это
поле `spinningthreads`.

Чтобы не тратить процессор впустую, одновременно крутится ограниченное число потоков.
Если за время спина работа не нашлась, поток засыпает.

Та же идея есть в [`sync.Mutex`](/go/book/sync-primitives): горутина, не сумевшая
захватить мьютекс, несколько раз коротко крутится в надежде, что владелец вот-вот его
отпустит, и только потом засыпает. Если горутина ждёт мьютекс дольше 1 мс, мьютекс
переходит в «голодный» режим и передаёт владение строго по очереди ожидания, чтобы
никто не ждал бесконечно.

## Sysmon

Кто посылает `SIGURG` засидевшейся горутине и кто отбирает P у застрявшего системного
вызова? Это делает **sysmon** — фоновый поток рантайма, который работает без P. Он
просыпается с интервалом от 20 мкс до 10 мс (чем спокойнее программа, тем реже) и:

- вытесняет горутины, которые выполняются дольше 10 мс;
- отбирает P у потоков, надолго застрявших в системных вызовах;
- проверяет сетевой поллер, если его давно никто не проверял;
- запускает сборку мусора, если её не было больше двух минут.

Без sysmon жадную горутину некому было бы вытеснить, а P, застрявший вместе с системным
вызовом, некому было бы вернуть.

## Частые ошибки

- **Полагаться на вытеснение вместо отмены.** Цикл без вызовов с Go 1.14 не заморозит
  программу, но и не остановится по отмене. Долгую работу делают отменяемой явно.
- **Считать, что потоков не больше `GOMAXPROCS`.** Блокирующие системные вызовы и cgo
  добавляют потоки сверх числа P. Настораживать должно не само превышение, а сотни
  потоков, застрявших в одних и тех же вызовах.
- **Не проверять `GOMAXPROCS` в контейнере.** На Go до 1.25 или с `go` ниже 1.25 в
  `go.mod` рантайм видит все ядра хоста. Проверьте значение в логах при старте.
- **Расставлять `runtime.Gosched()` «для надёжности».** Он не синхронизирует данные и не
  исправляет гонки, а в обычном коде только мешает планировщику.
- **Думать, что `GOMAXPROCS=1` убирает гонки.** Горутины по-прежнему чередуются в
  произвольных точках, включая середину `counter++`. Гонка данных определяется отсутствием
  happens-before, а не числом ядер.

## Попробуйте сами

При `GOMAXPROCS=1` запущена тысяча горутин, все ждут приёма из каналов. Сколько из них
могут одновременно выполнять Go-код? Нужна ли тысяча потоков, чтобы они все ждали? Теперь
другая ситуация: двадцать горутин одновременно сидят в блокирующих системных вызовах
чтения файла. Ограничивает ли `GOMAXPROCS=1` общее число потоков процесса?

### Проверка

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

Горутина в блокирующем системном вызове занимает свой поток M. Рантайм передаёт P
другому потоку, чтобы остальные горутины продолжали работать, поэтому потоков может стать
больше двадцати. `GOMAXPROCS` ограничивает одновременное выполнение Go-кода, а не общее
число потоков процесса.

## Что дальше

Эта глава объясняет поведение, которое видно во всех остальных. Дешёвые
[горутины](/go/book/goroutines) — следствие парковки без занятия потока и маленьких
стеков. Хорошее масштабирование [паттернов](/go/book/patterns) — следствие
work-stealing. Необходимость явно проверять [`ctx.Done()`](/go/book/context) в долгих
циклах — следствие того, что вытеснение ничего не отменяет. А сотни потоков в профиле
сервиса — повод искать блокирующие системные вызовы.

Вам почти никогда не придётся управлять планировщиком. Но объяснить, почему цикл без
вызовов когда-то замораживал программу и откуда в процессе сотня потоков, — признак того,
что модель конкурентности Go у вас сложилась целиком.
