35. Scheduler Go. G, M, P и work stealing
«Простые вещи должны оставаться простыми, а сложные — быть выполнимыми».
— Алан Кэй
В прошлой статье мы выяснили, что горутины — это легковесные структуры в памяти (User Space), которые невероятно дешево создавать: стартовые 2 КБ стека и переключение контекста всего за несколько десятков наносекунд. Но операционная система (Linux, macOS, Windows) ничего не знает о горутинах. Планировщик ОС управляет исключительно своими тяжеловесными системными потоками (OS Threads).
Возникает фундаментальная инженерная проблема: как эффективно распределить сотни тысяч независимых горутин по нескольким доступным ядрам процессора, не перегружая ядро Linux системными вызовами и не теряя драгоценные такты в очередях ожидания?
Этим занимается Планировщик Go (Go Scheduler).
Планировщик Go реализует модель M:N, где $M$ легковесных горутин мультиплексируются (выполняются) на $N$ потоках операционной системы.
Архитектура G-M-P
В ранних версиях Go (до 1.1) планировщик состоял только из горутин и потоков ОС, а все горутины лежали в одной глобальной очереди, защищенной гигантским мьютексом (Global Lock). Это работало ужасно: на многоядерных машинах потоки ОС постоянно ждали снятия блокировки, чтобы взять новую горутину, а кэши процессора (L1/L2) сбрасывались при каждом прыжке горутины между тредами.
В 2012 году Дмитрий Вьюков полностью переписал планировщик, добавив концепцию логических процессоров. Так родилась знаменитая модель G-M-P:
- G (Goroutine): Структура
g, описывающая саму горутину (стек, текущий указатель инструкцийPC, статус). Это просто данные. Сама по себе горутина ничего не исполняет. - M (Machine / Тред ОС): Структура
m, представляющая поток операционной системы. Именно $M$ запрашивается у ОС и исполняет машинный код. - P (Processor / Логический контекст): Структура
p, абстрактный ресурс или «контекст выполнения». Чтобы $M$ мог выполнять $G$, он обязан получить в свое распоряжение $P$.
flowchart TD
classDef process fill:#1e293b,stroke:#38bdf8,stroke-width:1px,color:#f8fafc
classDef success fill:#064e3b,stroke:#34d399,stroke-width:1px,color:#f8fafc
classDef error fill:#7f1d1d,stroke:#f87171,stroke-width:1px,color:#f8fafc
classDef warning fill:#78350f,stroke:#fbbf24,stroke-width:1px,color:#f8fafc
classDef entry fill:#1e3a5f,stroke:#38bdf8,stroke-width:2px,color:#f8fafc
classDef aux fill:#334155,stroke:#64748b,stroke-width:1px,color:#f8fafc
classDef system fill:#312e81,stroke:#a78bfa,stroke-width:1px,color:#f8fafc
classDef data fill:#132d29,stroke:#2dd4bf,stroke-width:1px,color:#f8fafc
classDef network fill:#881337,stroke:#fb7185,stroke-width:1px,color:#f8fafc
classDef accent fill:#1e1b4b,stroke:#818cf8,stroke-width:1px,color:#f8fafc
subgraph GMP["Модель G-M-P"]
P["Логический процессор (P)<br>LRQ (до 256 G) + mcache"]
M["Поток ОС (M)<br>Связано с P для исполнения"]
G["Активная горутина (G)<br>Код исполняется на CPU"]
P <--> M
M --> G
G --> Core["Физическое ядро CPU"]
end
Если поток $M$ надолго блокируется (например, на системном вызове), процессор $P$ отсоединяется от него вместе со всей своей очередью горутин и передается другому свободному потоку $M$. Ни одна готовая к работе горутина не простаивает!
Очереди горутин: Где живут задачи?
В системе есть два типа очередей для готовых к выполнению горутин (в статусе _Grunnable):
- LRQ (Local Run Queue): Локальная очередь внутри каждого $P$. Она реализована как lock-free кольцевой буфер и вмещает максимум 256 горутин. Доступ к ней происходит без системных блокировок, потому что в каждый момент времени ей владеет только один $M$. Это обеспечивает локальность данных в L1/L2 кэшах процессора.
- GRQ (Global Run Queue): Глобальная очередь рантайма. Сюда попадают горутины, если локальная очередь $P$ переполнилась (достигла лимита в 256), а также горутины, которые были разбужены сетевым поллером или сборщиком мусора. Доступ к ней защищен мьютексом, поэтому обращение к GRQ обходится дорого.
flowchart TD
classDef process fill:#1e293b,stroke:#38bdf8,stroke-width:1px,color:#f8fafc
classDef success fill:#064e3b,stroke:#34d399,stroke-width:1px,color:#f8fafc
classDef error fill:#7f1d1d,stroke:#f87171,stroke-width:1px,color:#f8fafc
classDef warning fill:#78350f,stroke:#fbbf24,stroke-width:1px,color:#f8fafc
classDef entry fill:#1e3a5f,stroke:#38bdf8,stroke-width:2px,color:#f8fafc
classDef aux fill:#334155,stroke:#64748b,stroke-width:1px,color:#f8fafc
classDef system fill:#312e81,stroke:#a78bfa,stroke-width:1px,color:#f8fafc
classDef data fill:#132d29,stroke:#2dd4bf,stroke-width:1px,color:#f8fafc
classDef network fill:#881337,stroke:#fb7185,stroke-width:1px,color:#f8fafc
classDef accent fill:#1e1b4b,stroke:#818cf8,stroke-width:1px,color:#f8fafc
GRQ["Глобальная очередь - GRQ<br>Неограниченная емкость (защищена мьютексом)"]
subgraph Core1 ["Ядро CPU 1"]
P1["Логический процессор - P1"]:::process
LRQ1["Локальная очередь - LRQ<br>Максимум 256 G (Lock-free)"]
M1["Тред ОС - M1<br>Исполняет код"]:::system
G1["Активная - G"]:::process
P1 --> LRQ1
P1 --> M1
M1 -->|"Выполняет"| G1
LRQ1 -.->|"Отдает задачу"| M1
end
subgraph Core2 ["Ядро CPU 2"]
P2["Логический процессор - P2"]:::process
LRQ2["Локальная очередь - LRQ<br>Пустая"]:::data
M2["Тред ОС - M2<br>Свободен"]:::system
P2 --> LRQ2
P2 --> M2
end
GRQ -.->|"M2 берет задачи из GRQ<br>с захватом мьютекса"| P2
Work Stealing (Кража работы)
Что происходит, если $M2$ (на Ядре 2) быстро выполнил все горутины из своей локальной очереди LRQ2? Он не засыпает, чтобы не тратить ресурсы ОС на пробуждение в будущем.
Вместо этого планировщик запускает агрессивный алгоритм поиска работы — Work Stealing.
flowchart LR
classDef process fill:#1e293b,stroke:#38bdf8,stroke-width:1px,color:#f8fafc
classDef success fill:#064e3b,stroke:#34d399,stroke-width:1px,color:#f8fafc
classDef error fill:#7f1d1d,stroke:#f87171,stroke-width:1px,color:#f8fafc
classDef warning fill:#78350f,stroke:#fbbf24,stroke-width:1px,color:#f8fafc
classDef entry fill:#1e3a5f,stroke:#38bdf8,stroke-width:2px,color:#f8fafc
classDef aux fill:#334155,stroke:#64748b,stroke-width:1px,color:#f8fafc
classDef system fill:#312e81,stroke:#a78bfa,stroke-width:1px,color:#f8fafc
classDef data fill:#132d29,stroke:#2dd4bf,stroke-width:1px,color:#f8fafc
classDef network fill:#881337,stroke:#fb7185,stroke-width:1px,color:#f8fafc
classDef accent fill:#1e1b4b,stroke:#818cf8,stroke-width:1px,color:#f8fafc
M["Свободный M"]:::process --> S1["1. Проверить свою LRQ"]
S1 -->|"Пусто"| S2["2. Проверить GRQ"]
S2 -->|"Пусто"| S3["3. Проверить Netpoll"]
S3 -->|"Пусто"| S4["4. Work Stealing:<br>Украсть 50% из LRQ чужого P"]
Порядок поиска работы для потока $M$ (функция findrunnable в рантайме):
- Проверить локальную очередь (LRQ).
- Проверить глобальную очередь (GRQ). (Берется сразу пачка горутин, чтобы оправдать захват мьютекса).
- Проверить сетевой поллер (Netpoll). Нет ли горутин, чьи сетевые I/O операции завершились?
- Кража (Work Stealing). $M$ случайным образом выбирает другой $P$ и крадет ровно половину горутин из его локальной очереди!
Кража половины очереди — это блестящее решение: с одной стороны, ворующий процессор сразу получает хороший запас задач и не будет отвлекать соседа частыми запросами; с другой стороны, у обкраденного процессора остается достаточный объем работы.
Блокировки: Syscall и Handoff
Mechanical Sympathy требует от нас понимания: что будет, если горутина сделает тяжелый синхронный системный вызов (например, fsync или CGO вызов)?
Если горутина делает вызов, ядро Linux блокирует системный поток $M$.
Если бы $P$ оставался привязанным к заблокированному $M$, все остальные горутины в LRQ этого $P$ зависли бы, ожидая завершения вызова, хотя другие ядра могли бы простаивать!
Для решения этой проблемы используется механизм Handoff (Передача контекста).
sequenceDiagram
autonumber
participant G1 as Горутина G1
participant M1 as Поток ОС M1
participant P1 as Процессор P1
participant M2 as Резервный Поток M2
participant OS as Ядро Linux (Syscall)
G1->>M1: Вызов синхронного Syscall (CGO / fsync)
M1->>OS: Переход в Ring 0 (Блокировка M1)
Note over M1,P1: sysmon замечает затянувшийся syscall
P1->>M1: Отцепление P1 от M1 (Handoff)
P1->>M2: Привязка P1 к новому/резервному M2
M2->>P1: Исполнение остальных горутин из LRQ
OS-->>M1: Syscall завершился
M1->>P1: Попытка вернуть P1 (если свободен)
Note over M1,G1: Если P1 занят: G1 идет в GRQ,<br/>а M1 засыпает в пул
- Рантайм обнаруживает, что $M1$ ушел в Syscall и задержался там: обычный системный вызов не отдаёт $P$ сразу, а фоновый поток
sysmonотбирает его, если вызов длится дольше одного своего тика (не меньше ~20 мкс) и у $P$ есть работа. Короткие вызовы (например, чтение из page cache) обходятся без передачи $P$; немедленно $P$ отдаётся только перед заведомо блокирующими вызовами (entersyscallblock). - $M1$ «отцепляется» от своего процессора $P1$, сохраняя при этом блокирующую горутину $G1$.
- Процессор $P1$ становится сиротой. Планировщик берет из резерва (или создает новый) свободный поток $M2$ и привязывает его к $P1$.
- $M2$ продолжает выполнять оставшиеся горутины из очереди $P1$.
- Когда Syscall на $M1$ завершается, он пытается вернуть себе любой свободный $P$. Если свободных $P$ нет, $M1$ кладет горутину $G1$ в Глобальную очередь, а сам засыпает и уходит в пул неактивных потоков.
Вытесняющая многозадачность (Preemption)
До Go 1.14 планировщик был кооперативным. Это означало, что горутина отдавала управление планировщику добровольно: при вызове функций (в прологе функции происходила проверка стека), аллокациях, блокировках на каналах или системных вызовах.
Но если вы писали бесконечный математический цикл без вызова функций, эта горутина захватывала $M$ навсегда. Хуже того, хватало одного такого цикла, чтобы остановить всё приложение: когда сборщику мусора требовалась остановка мира (stop-the-world), остальные горутины ждали, пока цикл дойдёт до точки вытеснения, а он не доходил.
Начиная с Go 1.14, планировщик стал Асинхронным вытесняющим (Asynchronous Preemptive).
В фоне работает специальный системный поток, который не привязан к $P$ — он называется sysmon (System Monitor).
sysmon периодически просыпается и проверяет статус всех $P$. Если он видит, что какая-то горутина выполняется дольше 10 миллисекунд без остановки, он отправляет системному потоку $M$ сигнал ОС (SIGURG в Linux / Unix).
Обработчик этого сигнала прерывает выполнение горутины, сохраняет её регистры, переводит её статус в _Grunnable, закидывает в глобальную очередь и передает процессорное время следующей задаче.
Итог
- G-M-P модель: Изолирует состояние очередей (в $P$) от потоков ОС ($M$), устраняя глобальные блокировки.
- Work Stealing: Распределяет нагрузку. Простаивающий $P$ крадет половину горутин у загруженного соседа.
- Handoff: Защищает от блокировок. Если $M$ заблокировался в Syscall, рантайм отвязывает $P$ и передает его новому $M$.
- sysmon и Вытеснение: Фоновый монитор следит за зависшими горутинами и вытесняет их через сигналы ОС (
SIGURG), если они работают дольше 10 мс.
Планировщик виртуозно управляет исполнением горутин. Но как этим независимым микро-потокам обмениваться данными, не наступая друг другу на пятки через классическую общую память и мьютексы? Для этого создатели Go возвели в абсолют концепцию из математической теории взаимодействующих последовательных процессов (CSP). Мы разберем её в следующей статье: 36. Каналы. Передача данных между горутинами.