Анатомия планировщика Go: от модели GMP и work-stealing до асинхронного вытеснения горутин
Язык Go завоевал признание во многом благодаря простоте работы с конкурентностью. Создание независимой задачи не требует сложных пулов: достаточно добавить ключевое слово go перед вызовом функции. За этим простым синтаксисом скрывается планировщик среды выполнения Go (Go Runtime Scheduler). Он позволяет запускать сотни тысяч задач параллельно, эффективно распределяя их по ядрам процессора и предотвращая деградацию производительности.
Почему операционной системе тяжело с миллионами потоков
В традиционных архитектурах многозадачность возлагается на операционную систему. Программа создает системные потоки (threads), а планировщик ядра распределяет их по физическим ядрам CPU. Каждый системный поток находится в одном из трех состояний:
- Executing: выполняется непосредственно на ядре процессора;
- Runnable: готов к выполнению и ожидает освобождения ядра;
- Waiting: заблокирован в ожидании внешнего события — сети, диска или мьютекса.
Для операционной системы управление потоками ограничено двумя факторами: памятью и ценой переключения контекста.
Стек системного потока фиксирован и обычно занимает 1–2 мегабайта. Попытка запустить 10 000 системных потоков потребует 10–20 гигабайт памяти только под служебные структуры стеков.
Второй фактор — переключение контекста (context switch). Чтобы передать процессор другому потоку, ОС сохраняет регистры, переключает кольца защиты между ядром и пространством пользователя и обновляет таблицы страниц памяти. Это сбрасывает аппаратные кэши CPU. Если потоков слишком много, процессор тратит на переключение контекста больше времени, чем на выполнение полезного кода.
| Параметр сравнения | Системный поток ОС (Thread) | Горутина Go (Goroutine) |
|---|---|---|
| Уровень управления | Пространство ядра (Kernel Space) | Пространство пользователя (User Space) |
| Начальный стек | 1–2 МБ (фиксированный) | Около 2 КБ (динамический рост) |
| Стоимость создания | Высокая (системный вызов ядра) | Низкая (внутренняя структура runtime) |
| Переключение контекста | 1–2 микросекунды (сброс кэшей) | Десятки наносекунд в user space |
| Масштабирование | Тысячи потоков на систему | Сотни тысяч и миллионы горутин |
Чтобы преодолеть эти ограничения, инженеры Go перенесли планирование в пространство пользователя, реализовав модель многозадачности M:N.
Модель GMP: разделение сущностей и ролей
Архитектура планировщика базируется на триаде сущностей модели GMP:
- G (Goroutine): легковесный поток выполнения программы со своим стеком и состоянием регистров.
- M (Machine): системный поток ОС, выполняющий инструкции на физическом процессоре.
- P (Processor): логический процессор планировщика Go, представляющий право на выполнение кода.
Число логических процессоров P ограничено переменной GOMAXPROCS и по умолчанию равно числу доступных ядер CPU (runtime.NumCPU()). Потоков M в системе может быть больше, однако выполнять Go-код могут только те потоки M, которые захватили свободный процессор P.
Такое разделение устраняет лишнюю нагрузку: количество активных потоков M точно соответствует вычислительным возможностям оборудования, минимизируя системные переключения контекста.
Очереди задач и избавление от блокировок
В ранних версиях Go планировщик использовал общую очередь задач (Global Run Queue, GRQ). Всякий раз освободившийся поток обращался к ней за новой горутиной.
При росте числа ядер общая очередь стала узким местом. Чтобы избежать состояния гонки, обращение к ней блокировалось мьютексом. При десятках ядер процессоры P тратили слишком много времени в очереди ожидания самого мьютекса.
Для масштабирования каждый логический процессор P получил собственную локальную очередь (Local Run Queue, LRQ) на 256 горутин. С этой очередью работает только закрепленный процессор P, поэтому операции не требуют блокировок мьютексом.
Глобальная очередь GRQ сохранилась для задач, не привязанных к конкретному P. Чтобы горутины в ней не застаивались, каждый процессор P ровно один раз за 61 шаг планирования обращается к GRQ под защитой мьютекса. Число 61 выбрано простым, чтобы разнести обращения разных процессоров по времени.
Балансировка нагрузки: алгоритм кражи задач (Work-stealing)
Если у процессора P закончились задачи в локальной очереди, он не простаивает, а применяет алгоритм кражи задач (work-stealing).
Порядок поиска работы логическим процессором:
- Проверка локальной очереди LRQ;
- Проверка глобальной очереди GRQ (каждый 61-й шаг или при пустой LRQ);
- Проверка сетевого поллера (netpoller) на готовые сетевые операции;
- Кража задач: процессор P выбирает случайного соседа и забирает половину горутин из его LRQ.
Это гарантирует равномерную загрузку всех доступных ядер без единого центрального диспетчера.
Блокирующие системные вызовы и механизм handoff
Операции с файлами на диске или вызовы библиотек через cgo блокируют системный поток M на неопределенное время. Если бы поток M оставался привязанным к процессору P, все остальные горутины из очереди этого P оказались бы замороженными.
Для предотвращения задержек планировщик использует передачу процессора (handoff):
- Перед блокирующим системным вызовом поток M переводит горутину в состояние ожидания вызова;
- Логический процессор P открепляется от блокирующегося потока M;
- Runtime передает процессор P другому свободному потоку M из пула (или запускает новый);
- Новый поток M продолжает выполнение оставшихся горутин из очереди P;
- После завершения вызова поток M ищет свободный процессор P. Если свободных P нет, горутина отправляется в глобальную очередь GRQ, а поток M засыпает.
Сетевой ввод-вывод и асинхронный поллер (netpoller)
Сетевой ввод-вывод в Go не блокирует системные потоки благодаря сетевому поллеру (netpoller). На уровне ОС Go переводит сокеты в неблокирующий режим с помощью механизмов epoll (Linux), kqueue (macOS) или IOCP (Windows).
Когда горутина ожидает поступления данных из сетевого соединения:
- Вызов чтения фиксирует отсутствие байтов в сокете;
- Горутина переводится в состояние Waiting и регистрируется в netpoller;
- Логический процессор P сразу переключается на выполнение следующей готовой горутины;
- Когда сетевая карта получает пакет, netpoller переводит горутину в статус Runnable и возвращает ее в очередь выполнения.
Это позволяет обслуживать сотни тысяч соединений силами небольшого пула потоков ОС.
Эволюция вытеснения: от проверок функций к сигналам ОС
До версии Go 1.14 планировщик был чисто кооперативным. Переключение горутин происходило при операциях с каналами, мьютексами, сетевом вводе-выводе или вызове функций (компилятор вставлял проверки в прологи функций).
Этот подход имел изъян: плотные циклы без вызовов функций вроде for {} полностью захватывали процессор. Горутина не уступала управление, блокируя сборщик мусора и другие задачи.
В Go 1.14 появилось асинхронное неблокирующее вытеснение. Системный поток мониторинга sysmon отслеживает горутины, работающие дольше 10 миллисекунд, и отправляет потоку M сигнал SIGURG (на Unix). Обработчик сигнала прерывает выполнение, сохраняет контекст и возвращает горутину в очередь. Разработчикам системного кода важно учитывать, что медленные системные вызовы при этом могут прерываться ошибкой EINTR, требуя корректного повтора операции.
Чеклист для инженера
- Контролируйте лимиты в контейнерах: В Kubernetes значение
GOMAXPROCSможет считывать все ядра физического узла, вызывая лишние потоки и троттлинг. Используйтеuber-go/automaxprocs. - Изолируйте блокирующий cgo-код: Массовые вызовы C-библиотек запускают механизм handoff и создают избыточные системные потоки, увеличивая расход памяти.
- Избегайте утечек горутин: Забытые горутины, заблокированные на незакрытых каналах или сетевых запросах без таймаутов, удерживают память стека до завершения процесса.
- Используйте трассировку: Инструмент
go tool traceпозволяет увидеть переключения контекста, паузы в очередях и работу netpoller в реальном времени.
