Назад к блогу

Что внутри планировщика горутин Go: G, M, P и как всё крутится

Что внутри планировщика горутин Go: G, M, P и как всё крутится

> Серия «Что внутри?» — выпуск о рантайме Go: модель G/M/P, локальные и глобальная очереди, work stealing, сетевая подсистема и вытеснение. Почему миллионы горутин уживаются в десятке потоков ОС.

Go называют языком с «горутинами», но сами по себе горутины — это лишь половина истории. Вторая половина — планировщик рантайма: хитроумный диспетчер, который раскладывает миллионы лёгких горутин по небольшому числу потоков операционной системы, умеет воровать работу у соседей, не блокирует потоки на ожидании сети и аккуратно вытесняет слишком долго бегущие горутины.

В этом выпуске разберём модель G/M/P, очереди и work stealing, сетевой поллер и механизм вытеснения — и поймём, почему горутина «стоит дёшево», а создавать их тысячами можно без страха.

Проблема: как уместить миллион «потоков» в десяток потоков ОС

У потоков операционной системы высокая цена: каждый поток — это стек в несколько мегабайт, системные вызовы на создание и переключение контекста через ядро. Создать миллион потоков невозможно — памяти и накладных расходов не хватит.

Горутина — это не поток ОС. Это крошечная структура в памяти рантайма: стек (который начинается с ~2 КБ и растёт по мере необходимости), состояние, указатели для планировщика. Переключение между горутинами делает сам рантайм в пользовательском пространстве, без захода в ядро — поэтому оно в разы дешевле переключения потоков.

Поток ОС: стек 1–8 МБ, переключение через ядро   → тысячи — уже тяжело
Горутина: стек ~2 КБ и растёт, планировщик в рантайме → миллионы — нормально

Но миллион лёгких «потоков» кто-то должен исполнять на реальных ядрах процессора. Эту связку и строит планировщик.

Модель G / M / P: три сущности

Классическая модель планировщика Go — три буквы:

  • G (goroutine) — горутина: код, который нужно выполнить, её стек и состояние (готов, ждёт, работает).
  • M (machine/thread) — поток операционной системы, который реально исполняет код. M не знает про горутины «вообще» — он выполняет текущую G.
  • P (processor) — «контекст процессора»: виртуальный процессорный слот, у которого есть своя локальная очередь готовых горутин. Именно P связывает G и M: чтобы выполнять горутину, M должен иметь P.
            P1                    P2
      ┌────────────┐        ┌────────────┐
      │ local runq │        │ local runq │
      │ [G G G G]  │        │ [G G]      │
      └─────┬──────┘        └─────┬──────┘
            │ G                  │ G
        ┌───▼────┐           ┌───▼────┐
        │ M1     │           │ M2     │     потоки ОС
        └───┬────┘           └────────┘
            ▼
          ядро CPU0          ядро CPU1

   GLOBAL runq: [ G G G G G G G ]   ← общая очередь на всех

Ключевое ограничение: параллельно исполняется не больше горутин, чем P, а число P равно GOMAXPROCS (по умолчанию — числу логических ядер). Потоков M может быть больше, чем P, — лишние M засыпают или временно уходят в системные вызовы. Горутin ждать не обязаны: сколько бы их ни было, планировщик распределяет их по P.

Очереди: локальная, runnext и глобальная

У каждой P есть локальная очередь готовых горутин (кольцевой буфер фиксированной ёмкости, обычно до 256), а кроме неё — специальный слот runnext для «следующей» горутины (см. про справедливость ниже). Поверх всех P есть глобальная очередь — для горутин, которые не привязаны к конкретному P.

Куда кладётся новая горутина (go f())?

новая горутина
      │
      ▼
 создатель ищет свободный P и кладёт G в ЕГО локальную очередь
 (в runnext или runq), иначе — в глобальную

Это важная оптимизация локальности: только что созданная горутина с высокой вероятностью будет исполнена тем же M, который её создал, — данные останутся в кэше процессора.

Главный цикл планировщика: откуда берётся следующая горутина

Каждый M с P крутится в цикле «найди работу, выполни». Приоритет поиска примерно такой:

  1. Взять runnext (если есть) — это горутина, которую положили «следующей».
  2. Взять горутину из локальной очереди P.
  3. Если локальная пуста — заглянуть в глобальную очередь (но не каждый раз, чтобы не создавать «узкое место»: локальную очередь обслуживают чаще, глобальную — периодически, с учётом счётчика).
  4. Если и там пусто — попробовать украсть работу у других P.
  5. Если украсть нечего — проверить сетевой поллер (вдруг проснулась горутина, ждавшая сети).
  6. Если и там пусто — M засыпает.
цикл M:
  runnext ? → бери
  local runq ? → бери
  глобальная (периодически) ? → бери
  steal у других P ? → бери
  netpoll ? → бери
  иначе → спи, пока не разбудит sysmon

Пункт 4 — «work stealing» — сердце балансировки нагрузки.

Work stealing: зачем воровать у соседа

Если один P перегружен, а другой простаивает, планировщик крадёт горутины из локальной очереди занятого P. Механика:

  • P с пустой локальной очередью выбирает случайного «богатого» соседа.
  • Забирает у него часть горутин — обычно с «хвоста» очереди (несколько элементов, а не первую) — чтобы не воровать ту самую «свежую» работу, которую сосед вот-вот начнёт.
P1 (занят):  runq [ A B C D E F ]      P2 (простаивает)
                    │                        │
                    └──── steal [E F] ───────►│  P2 исполняет E, F

Зачем воровать с хвоста? Локальность: недавно добавленные горутины (голова очереди) с высокой вероятностью скоро понадобятся тому же M и его данным в кэше; «хвост» — самая старая, «остывшая» работа, и её отдать соседу наименее больно. Суммарно это даёт хорошую балансировку без централизованной очереди, которая стала бы узким местом на многоядерных машинах.

Справедливость: почему одна горутина не «залипла» в очереди

Если горутины класть всегда в начало локальной очереди, непрерывный поток новых горутин может «задавить» старые. Чтобы этого не случилось, планировщик:

  • периодически кладёт «долгоживущую» горутину в глобальную очередь, откуда её могут забрать другие P;
  • использует слот runnext как маленькую «очередь с приоритетом» для последней созданной горутины, не давая ей вытеснить всех остальных.

Благодаря этому даже при постоянном создании горутин каждая из них рано или поздно получает процессорное время.

Сеть: как горутина «ждёт ответа», не блокируя поток

Самая частая причина, по которой программа «спит», — сеть. Если бы каждая горутина, ждущая ответа по TCP, блокировала поток ОС, мы бы вернулись к проблеме «тысяча потоков». Go решает это через сетевой поллер (netpoller).

Всё сетевые операции в Go — неблокирующие на уровне ОС: рантайм использует epoll (Linux) или kqueue (macOS/BSD). Когда горутина делает сетевой вызов, который не может завершиться сразу:

  1. Рантайм регистрирует файловый дескриптор в поллере.
  2. Горутина уходит в состояние ожидания, а M с P продолжает работать — исполняет другие горутины.
  3. Когда по дескриптору появляются данные или он становится готов, поллер сообщает планировщику.
  4. Планировщик кладёт проснувшуюся горутину в очередь, и она продолжается с места, где остановилась.
горутина: conn.Read(...)
   │  данных пока нет
   ▼
 регистрирую fd в netpoll (epoll)  ── M свободен, работает дальше
   │
   │  (данные пришли)
   ▼
 epoll сообщает → горутина снова готова → в очередь P

Результат: тысячи одновременных сетевых соединений обслуживаются несколькими потоками, а не тысячей потоков. Именно поэтому Go-сервер легко держит десятки тысяч открытых соединений.

Блокирующий системный вызов: M уходит, P остаётся

Не всякий ввод-вывод можно сделать неблокирующим. Что если горутина вызвала честный блокирующий системный вызов (например, чтение с диска или exec)? Решение элегантное:

  • Горутина уходит в системный вызов вместе со своим M — ведь вызов блокирует поток целиком.
  • Но P при этом освобождается и передаётся другому M (или для него создаётся новый M из пула). Локальная очередь P не простаивает — её продолжает исполнять другой поток.
  • Когда системный вызов завершается, M возвращается, ищет свободный P (или встаёт в очередь ожидания) и продолжает работу.
G вызывает блокирующий syscall
   │
   ▼
 M уходит в ядро с G          P освобождается
   │                              │
   │  (в ядре)                    ▼
   │                        другой M / новый M берёт P и исполняет очередь
   ▼
 M возвращается, ищет свободный P → продолжает

Здесь кроется причина, почему число M может превышать число P: часть потоков «зависает» в блокирующих вызовах, и рантайму приходится заводить дополнительные потоки, чтобы P не простаивали.

Стеки: маленькие и растущие

Горутина «дешёвая» ещё и потому, что её стек начинается с ~2 КБ. Но код может уйти в глубокую рекурсию — что тогда?

  • Стек горутины растёт по требованию: когда он заполняется, рантайм выделяет больший и копирует содержимое (с Go 1.3 стеки непрерывные, раньше использовались сегментные). Указатели внутри обновляются специальной ремапировкой.
  • Расти может до больших размеров — миллионы горутин с крошечными стеками живут в памяти, которая для потоков ОС давно бы закончилась.

Растущий стек — ещё одна причина, почему создание горутины стоит наносекунды-микросекунды, а не системный вызов с аллокацией мегабайт.

Вытеснение: почему горутина не захватывает CPU навсегда

Раньше Go был кооперативным: горутина отдавала управление только в «точках» (выделение памяти, вызовы, каналы). Если горутина попадала в бесконечный цикл без таких точек, она могла занять P навсегда. Начиная с Go 1.14 появилось асинхронное вытеснение.

За этим стоит системный монитор sysmon — отдельная фоновая «нить» рантайма, которая:

  • просыпается периодически;
  • замечает горутину, работающую дольше ~10 мс;
  • посылает потоку сигнал (на Linux — SIGURG), который прерывает выполнение;
  • горутина сохраняет состояние, и планировщик забирает управление — можно переключиться на другую работу.
sysmon ──► G работает уже >10 мс
        │
        ▼
   сигнал SIGURG → асинхронное прерывание
        │
        ▼
   G сохраняет контекст, планировщик решает: продолжить или переключиться

Вытеснение не «убивает» горутину — она просто становится готовой и продолжит позже, как только получит процессор. Это делает долгие вычисления справедливыми даже без явных точек кооперации.

Собираем всё вместе: что происходит при go f()

Проследим полный путь от вызова до выполнения:

  1. Создаётся G: выделяется структура, стек ~2 КБ.
  2. G кладётся в очередь: в runnext или локальную очередь P текущего M (или в глобальную, если подходящего P нет).
  3. M с P берёт G из своей очереди (или крадёт, или забирает из глобальной/netpoll).
  4. G исполняется на стеке; при сетевом ожидании — переходит в netpoll и освобождает M; при блокирующем syscall — M уходит, P передаётся другому M.
  5. Через ~10 мс непрерывной работы sysmon может вытеснить G сигналом.
  6. G завершается → M ищет следующую работу.
go f()
  → создаётся G { стек ~2КБ, состояние }
  → в очередь P (runnext / runq) или глобальную
  → M берёт G (свою / украденную / глобальную / из netpoll)
  → выполняется
       ├─ сеть: ждёт в netpoll, M свободен
       ├─ блокирующий syscall: M уходит, P передаётся другому M
       └─ >10мс: sysmon → SIGURG → вытеснение
  → G готова снова или завершилась → цикл продолжается

Параметры и практические советы

  • GOMAXPROCS — сколько P (параллельных исполнителей). По умолчанию — число логических ядер. На машине, где Go-программа живёт в контейнере с лимитом CPU, значение по умолчанию может оказаться больше реально доступных ядер (историческая ловушка) — стоит проверить.
  • Тысячи горутин — нормально, миллионы — тоже возможны, но помните: каждая горутина держит свой стек. Куча короткоживущих горутин — это нормальная работа рантайма, не «утечка».
  • Не создавайте «фоновый поток на каждое соединение» — в Go для этого есть горутины + netpoll, которые обходятся без потока ОС на соединение.
  • runtime.Gosched() — редкая необходимость: планировщик и так справедлив; вызывать его вручную обычно не нужно.
  • Если горутина «висит» в чистом вычислении дольше 10 мс — она будет вытеснена, но только если рантайм может прервать её сигналом; очень длинные «плотные» циклы на CGO — отдельная история.

Состояния горутины: жизненный цикл

У каждой горутины есть состояние, и понимание переходов — ключ ко всей модели:

        go f()
           │
           ▼
   ┌─ _Grunnable ───────────► _Grunning ──────────┐
   │   (в очереди)                │               │
   │                              │               │
   │   планировщик выбрал G       │  завершилась  │
   │                              │               ▼
   │                              │           _Gdead → переиспользование
   │   сеть/канал/таймер/            │
   │   syscall блокируют          │
   │           ▲                  ▼
   │           └───────── _Gwaiting (спит, пока не разбудят)
   └────────────────────────────────────────────┘
  • Grunnable — готова исполняться: лежит в локальной/глобальной очереди.
  • Grunning — выполняется на M с P.
  • Gwaiting — ждёт события: данные из сети, канал, таймер, syscall.
  • Gdead — завершилась; структура переиспользуется (рантайм не тратит аллокации на каждую новую горутину).

Структура G — это десятки полей: указатель на стек, сохранённый контекст (для переключения), ссылка на M, на котором она бежала, состояние, связи для очередей, и так далее. Именно потому, что это данные в памяти рантайма, а не объект ядра, переключение обходится дёшево.

Таймеры: как Go «спит до нужного времени»

time.Sleep, time.After, context.WithTimeout — всё это таймеры рантайма. У каждого P есть своя куча таймеров (binary heap по времени срабатывания). Когда горутина засыпает на таймере:

  1. Таймер кладётся в кучу того P, на котором горутина засыпает.
  2. M не «спит вслепую»: планировщик знает ближайший таймер и сообщает ОС, до какого времени можно спать.
  3. Когда время приходит, горутина становится Grunnable и попадает в очередь.

Разделение куч по P убирает конкуренцию за общий «словарь таймеров»: на многоядерной машине тысячи таймеров обслуживаются без единой горячей точки.

Каналы и select: как горутины спят и просыпаются

Канал — это, по сути, очередь + список «ждущих». Когда горутина делает ch <- v, а буфера нет и получателя нет, она «паркуется»: сохраняет свой sudog (структуру ожидания) в списке отправителей канала и уходит в Gwaiting. Получатель позже найдёт её в списке, заберёт значение и разбудит (goready).

G1: ch <- v        (буфер пуст, получателей нет)
   │  sudog(G1) → список отправителей канала
   ▼
G1: _Gwaiting      (M свободен, работает дальше)

G2: v := <-ch
   │  канал видит ждущего отправителя
   ▼
G1: _Grunnable → очередь P → исполняется

select — то же самое, но с несколькими каналами: горутина регистрируется в списках ожидания всех каналов из select, и первое сработавшее событие будит её. Никаких «поллинг-циклов» — всё на очередях событий.

sysmon: сторож, таймеры, GC и вытеснение

Отдельная роль в рантайме — системный монитор sysmon. Это фоновая логика рантайма (не горутина), которая периодически просыпается и делает «хозяйственные» дела:

  • проверяет, не работает ли какая-то горутина слишком долго (→ вытеснение, см. ниже);
  • будит спящие P/M, если появилась работа (например, проснулся netpoll или пришёл таймер);
  • инициирует сборку мусора при необходимости;
  • замечает M, застрявшие в блокирующих системных вызовах, и «отбирает» у них P, чтобы параллелизм не простаивал.

sysmon — это то, что превращает «кооперативный» планировщик в по-настоящему вытесняющий, не полагаясь на вежливость горутин.

Асинхронное вытеснение: как прервать «честный» цикл

До Go 1.14 планировщик был кооперативным: переключение происходило только в безопасных точках — при выделении памяти, вызове функций, работе с каналами. Бесконечный цикл без таких точек мог занять P навсегда. Решение — асинхронное вытеснение:

  1. sysmon замечает, что горутина исполняется дольше порога (≈10 мс).
  2. Потоку посылается сигнал — на Linux это SIGURG.
  3. Обработчик сигнала в рантайме останавливает горутину в произвольной точке, сохраняет контекст.
  4. Горutin становится Grunnable, планировщик может выбрать другую работу.
goroutine: for { x += 1 }          // «плотный» цикл, без точек кооперации
     ▲
     │  прошло ~10 мс
sysmon ──► SIGURG потоку
     │
     ▼
 обработчик: сохранить контекст G → G в очередь → планировщик решит

Есть исключения: пока горутина находится в syscall или в C-коде (cgo), прервать её сигналом нельзя безопасно — вытеснение ждёт возврата в Go-код. Поэтому очень «плотные» cgo-циклы всё ещё могут «держать» M — это известная оговорка.

Work stealing детальнее: почему воруют с хвоста

Когда у P кончилась работа, он не сидит сложа руки: пробует украсть у случайного соседа. Алгоритм итеративный:

  1. P выбирает случайного другого P.
  2. Если у того в локальной очереди больше одной горутины — забирает часть с хвоста очереди.
  3. Если сосед пуст — пробует следующего случайного.
  4. Перебрав несколько и не найдя работы, P проверяет глобальную очередь и netpoll; если и там пусто — засыпает.
P1: runq [ 1 2 3 4 5 6 7 8 ]        P2 пуст
P2 крадёт с хвоста: [7 8] → исполняет
(голова [1 2 …] остаётся у P1 — свежая локальная работа)

Почему хвост? Горутины, попавшие в очередь недавно (голова), с высокой вероятностью относятся к текущему контексту P — их данные ещё в кэше его ядра. Забирать их — значит ломать локальность. Хвост — самая «старая» работа, отдать её наименее болезненно. На практике это даёт хорошую балансировку при почти нулевой конкуренции за очереди.

Глобальная очередь и справедливость

Горutin в глобальную очередь попадают в нескольких случаях: когда локальная очередь переполнена, когда горутину специально «разжаловали» для справедливости, или когда новый P создаётся и ему некуда положить работу. Локальные очереди обслуживаются в первую очередь — иначе любое «проснувшееся» событие создавало бы гонку за глобальную очередь. Но чтобы глобальная очередь не голодала, планировщик периодически (после нескольких десятков локальных выборок) заглядывает в неё и забирает порцию. Плюс «долгоживущую» горутину периодически возвращают в глобальную очередь, чтобы другие P могли её подхватить — так одна бесконечная задача не задушит все остальные на своём P.

Сборка мусора и планировщик: safe points

Планировщик и GC живут в одном рантайме и должны договариваться. Для этого у горутин есть безопасные точки (safe points) — места, где можно безопасно остановить выполнение для GC. При старте сборки мусора рантайм останавливает горутины на таких точках, выполняет нужные фазы (например, остановку мира на короткое время) и продолжает. Механизм вытеснения (сигналы) используется и здесь: если горутина не дошла до safe point, её «подталкивают» сигналом. Поэтому в профилях вы иногда видите задержки, связанные со «сборкой мусора» даже в вычислениях — это планировщик синхронизирует горутины с GC.

Диагностика и практические советы

Инструменты, которые стоит знать:

  • GOMAXPROCS — число P; в контейнерах с лимитом CPU проверяйте, что рантайм видит правильное число ядер.
  • GODEBUG=schedtrace=1000 — каждую секунду печатает состояние планировщика: сколько P, M, горутин в очередях, сколько спят. Это лучший способ «увидеть» планировщик глазами.
  • runtime/trace — детальный trace событий планировщика: создание горутин, парковка, work stealing, syscall. Идеален для диагностики «почему всё стоит».
  • Профиль CPU (pprof) — покажет, где горутины проводят время, включая runtime.schedule, syscall и netpoll.

Типовые проблемы, которые вы сможете увидеть:

  • горутины «зависли» в chan send/recv — дедлок или не сбалансированный продюсер/потребитель;
  • много syscall и мало параллелизма — возможно, задача блокирует потоки чаще, чем нужно;
  • много runtime.schedule — слишком много горутин на единицу работы: переключения съедают CPU (обычно это видно как «planning overhead»).

Что дальше

Мы разобрали, как планировщик Go решает главную задачу: исполнить миллионы лёгких горутин на малом числе потоков ОС. Модель G/M/P, локальные очереди с work stealing, сетевой поллер вместо «потока на соединение» и асинхронное вытеснение — вот те киты, на которых стоит конкурентность Go. Теперь, когда вы напишете go func() или увидите в профиле runtime.schedule, вы будете знать, что происходит под капотом.

В следующих выпусках серии «Что внутри?» — почему Кафка не теряет данные и как распределённые системы добиваются надёжности.

Источники

Похожее