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:

  1. G (Goroutine): Структура g, описывающая саму горутину (стек, текущий указатель инструкций PC, статус). Это просто данные. Сама по себе горутина ничего не исполняет.
  2. M (Machine / Тред ОС): Структура m, представляющая поток операционной системы. Именно $M$ запрашивается у ОС и исполняет машинный код.
  3. 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):

  1. LRQ (Local Run Queue): Локальная очередь внутри каждого $P$. Она реализована как lock-free кольцевой буфер и вмещает максимум 256 горутин. Доступ к ней происходит без системных блокировок, потому что в каждый момент времени ей владеет только один $M$. Это обеспечивает локальность данных в L1/L2 кэшах процессора.
  2. 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 в рантайме):

  1. Проверить локальную очередь (LRQ).
  2. Проверить глобальную очередь (GRQ). (Берется сразу пачка горутин, чтобы оправдать захват мьютекса).
  3. Проверить сетевой поллер (Netpoll). Нет ли горутин, чьи сетевые I/O операции завершились?
  4. Кража (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 засыпает в пул
  
  1. Рантайм обнаруживает, что $M1$ ушел в Syscall и задержался там: обычный системный вызов не отдаёт $P$ сразу, а фоновый поток sysmon отбирает его, если вызов длится дольше одного своего тика (не меньше ~20 мкс) и у $P$ есть работа. Короткие вызовы (например, чтение из page cache) обходятся без передачи $P$; немедленно $P$ отдаётся только перед заведомо блокирующими вызовами (entersyscallblock).
  2. $M1$ «отцепляется» от своего процессора $P1$, сохраняя при этом блокирующую горутину $G1$.
  3. Процессор $P1$ становится сиротой. Планировщик берет из резерва (или создает новый) свободный поток $M2$ и привязывает его к $P1$.
  4. $M2$ продолжает выполнять оставшиеся горутины из очереди $P1$.
  5. Когда 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, закидывает в глобальную очередь и передает процессорное время следующей задаче.


Итог

  1. G-M-P модель: Изолирует состояние очередей (в $P$) от потоков ОС ($M$), устраняя глобальные блокировки.
  2. Work Stealing: Распределяет нагрузку. Простаивающий $P$ крадет половину горутин у загруженного соседа.
  3. Handoff: Защищает от блокировок. Если $M$ заблокировался в Syscall, рантайм отвязывает $P$ и передает его новому $M$.
  4. sysmon и Вытеснение: Фоновый монитор следит за зависшими горутинами и вытесняет их через сигналы ОС (SIGURG), если они работают дольше 10 мс.

Планировщик виртуозно управляет исполнением горутин. Но как этим независимым микро-потокам обмениваться данными, не наступая друг другу на пятки через классическую общую память и мьютексы? Для этого создатели Go возвели в абсолют концепцию из математической теории взаимодействующих последовательных процессов (CSP). Мы разберем её в следующей статье: 36. Каналы. Передача данных между горутинами.