> Серия «Что внутри?» — выпуск о рантайме 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 крутится в цикле «найди работу, выполни». Приоритет поиска примерно такой:
- Взять
runnext(если есть) — это горутина, которую положили «следующей». - Взять горутину из локальной очереди P.
- Если локальная пуста — заглянуть в глобальную очередь (но не каждый раз, чтобы не создавать «узкое место»: локальную очередь обслуживают чаще, глобальную — периодически, с учётом счётчика).
- Если и там пусто — попробовать украсть работу у других P.
- Если украсть нечего — проверить сетевой поллер (вдруг проснулась горутина, ждавшая сети).
- Если и там пусто — 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). Когда горутина делает сетевой вызов, который не может завершиться сразу:
- Рантайм регистрирует файловый дескриптор в поллере.
- Горутина уходит в состояние ожидания, а M с P продолжает работать — исполняет другие горутины.
- Когда по дескриптору появляются данные или он становится готов, поллер сообщает планировщику.
- Планировщик кладёт проснувшуюся горутину в очередь, и она продолжается с места, где остановилась.
горутина: 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()
Проследим полный путь от вызова до выполнения:
- Создаётся G: выделяется структура, стек ~2 КБ.
- G кладётся в очередь: в
runnextили локальную очередь P текущего M (или в глобальную, если подходящего P нет). - M с P берёт G из своей очереди (или крадёт, или забирает из глобальной/netpoll).
- G исполняется на стеке; при сетевом ожидании — переходит в netpoll и освобождает M; при блокирующем syscall — M уходит, P передаётся другому M.
- Через ~10 мс непрерывной работы sysmon может вытеснить G сигналом.
- 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 по времени срабатывания). Когда горутина засыпает на таймере:
- Таймер кладётся в кучу того P, на котором горутина засыпает.
- M не «спит вслепую»: планировщик знает ближайший таймер и сообщает ОС, до какого времени можно спать.
- Когда время приходит, горутина становится 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 навсегда. Решение — асинхронное вытеснение:
- sysmon замечает, что горутина исполняется дольше порога (≈10 мс).
- Потоку посылается сигнал — на Linux это
SIGURG. - Обработчик сигнала в рантайме останавливает горутину в произвольной точке, сохраняет контекст.
- Горutin становится Grunnable, планировщик может выбрать другую работу.
goroutine: for { x += 1 } // «плотный» цикл, без точек кооперации
▲
│ прошло ~10 мс
sysmon ──► SIGURG потоку
│
▼
обработчик: сохранить контекст G → G в очередь → планировщик решитЕсть исключения: пока горутина находится в syscall или в C-коде (cgo), прервать её сигналом нельзя безопасно — вытеснение ждёт возврата в Go-код. Поэтому очень «плотные» cgo-циклы всё ещё могут «держать» M — это известная оговорка.
Work stealing детальнее: почему воруют с хвоста
Когда у P кончилась работа, он не сидит сложа руки: пробует украсть у случайного соседа. Алгоритм итеративный:
- P выбирает случайного другого P.
- Если у того в локальной очереди больше одной горутины — забирает часть с хвоста очереди.
- Если сосед пуст — пробует следующего случайного.
- Перебрав несколько и не найдя работы, 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, вы будете знать, что происходит под капотом.
В следующих выпусках серии «Что внутри?» — почему Кафка не теряет данные и как распределённые системы добиваются надёжности.