Планировщик горутин в Go: как это устроено внутри
Почему миллион горутин помещается в память, где случается блокировка на syscall и что означает GOMAXPROCS. Разбор модели G-M-P на уровне механизма планировщика Go 1.22+.

Запустите миллион горутин в Go — программа съест около гигабайта памяти и продолжит работать. Запустите миллион потоков ОС — система упадёт задолго до этого. Разница в том, что горутина стартует с 2 КБ стека и планируется в пользовательском пространстве, без перехода в ядро на каждое переключение. За этим стоит планировщик, который распределяет тысячи горутин по горстке потоков ОС. Разберём его модель G-M-P, механизм work stealing, и то, где именно горутина может застрять, хотя код выглядит неблокирующим. Разбор актуален для Go 1.22+, но базовая модель не менялась с версии 1.14, где появилась асинхронная вытесняющая приостановка.
Три буквы, из которых собран планировщик
Планировщик оперирует тремя сущностями. Их обозначения взяли из исходников рантайма — их вы увидите в трассировках, дампах и профилях.
- G (goroutine) — сама горутина: указатель на стек, состояние, ссылка на функцию. Их могут быть миллионы.
- M (machine) — поток ОС. Именно M выполняет код на процессоре. Их число ограничено параметром
runtime.GOMAXPROCSдля активных M плюс дополнительные для блокирующих операций. - P (processor) — логический контекст выполнения, посредник между G и M. У P есть своя локальная очередь готовых горутин. Число P фиксировано и равно
GOMAXPROCS.
Чтобы M выполнял горутину, ему нужно захватить P. Нет свободного P — M простаивает или паркуется. Именно P, а не M, ограничивает параллелизм: сколько P, столько горутин исполняется на процессорах одновременно.
* Work stealing — приём балансировки, при котором простаивающий исполнитель забирает задачи из очереди занятого соседа, а не ждёт, пока ему их раздадут централизованно.
Зачем понадобился P
В ранних версиях Go (до 1.1) была только модель M и глобальная очередь горутин под одним мьютексом. На многоядерной машине потоки дрались за этот мьютекс, и планирование становилось узким местом. P добавили, чтобы дать каждому исполнителю локальную очередь без блокировок и включить work stealing между ними. Глобальная очередь осталась, но она теперь запасной путь, а не основной.
Как горутина попадает на процессор
Когда вы пишете go doWork(), рантайм создаёт структуру G и кладёт её в локальную очередь текущего P. Дальнейший путь такой:
- M, привязанный к этому P, доработает текущую горутину до точки переключения и заглянет в локальную очередь P.
- Если в локальной очереди есть готовая G — берёт её. Это самый частый и дешёвый случай, без блокировок.
- Локальная очередь пуста — M проверяет глобальную очередь (примерно раз в 61 такт планирования, чтобы глобальная очередь не голодала).
- Глобальная пуста — M пытается украсть половину горутин из локальной очереди случайного другого P (work stealing).
- Красть не у кого — M паркуется, освобождая P.
Локальная очередь P — кольцевой буфер на 256 элементов. Когда она переполняется, половина горутин уезжает в глобальную очередь. Это балансирует нагрузку, но добавляет обращение к общему мьютексу — поэтому взрывной рост числа горутин в одной точке кода не бесплатен.
Число P задаёт параллелизм, число M — только конкурентность на блокирующих операциях. Смешивать эти два понятия — типичная причина неверных выводов о том, почему программа не масштабируется по ядрам.
Точки переключения: где горутина уступает место
Планировщик Go кооперативный по своей природе, но с 1.14 добавлена и вытесняющая приостановка. Переключение горутины происходит в нескольких ситуациях:
- Вызов
go, канальные операции, мьютексы,time.Sleep— горутина добровольно уступает, потому что ей нечего делать прямо сейчас. - Системный вызов — блокирующий syscall отвязывает M от P (об этом ниже).
- Вызов функции — в прологе функции рантайм проверяет флаг приостановки. Это старый механизм, из-за него горутина без вызовов функций могла зависнуть.
- Асинхронная приостановка (с 1.14) — специальный поток sysmon посылает горутине сигнал, если она крутится дольше 10 мс без единой точки переключения. Плотный цикл вроде
for {}теперь тоже прерывается.
Что делает sysmon
sysmon — фоновый поток без привязки к P. Он периодически обходит планировщик и решает системные вопросы: приостанавливает засидевшиеся горутины, отбирает P у M, застрявших на длинном syscall, запускает сборку мусора по таймеру, возвращает память ОС. Работает он в цикле с растущим интервалом от 20 микросекунд до 10 миллисекунд.
Блокирующие системные вызовы: где M и P расходятся
Это самая частая причина недоумения. Пока горутина исполняет обычный Go-код, всё крутится в пользовательском пространстве. Но при блокирующем syscall — например, чтении из файла или обращении к неготовому сетевому дескриптору без сетевого поллера — M уходит в ядро и не может обслуживать другие горутины.
Рантайм это предвидит. Перед входом в syscall M отвязывается от своего P. Освободившийся P может подхватить другой M (из пула или новый), и остальные горутины продолжают работать. Когда syscall завершится, M попытается вернуть себе P:
- Свободный P есть — M забирает его и продолжает исполнять свою горутину.
- Свободного P нет — горутина отправляется в глобальную очередь, а сам M паркуется.
Отсюда практическое следствие: программа, активно работающая с блокирующими syscall, может держать заметно больше потоков ОС, чем GOMAXPROCS. Верхний предел числа M — runtime.SetMaxThreads, по умолчанию 10000. Упереться в него — обычно признак утечки блокировок, а не нормальной работы.
Сеть — особый случай
Сетевые операции не блокируют M целиком. Go использует интегрированный сетевой поллер (epoll на Linux, kqueue на BSD/macOS, IOCP на Windows). Когда горутина ждёт данные из сокета, она паркуется, а M освобождается для других задач. Готовность дескриптора поллер обнаруживает асинхронно и возвращает горутину в очередь. Именно поэтому сервер на Go держит десятки тысяч соединений горсткой потоков.
GOMAXPROCS: что этот параметр меняет на самом деле
По умолчанию GOMAXPROCS равен числу доступных логических ядер. Он задаёт количество P, то есть сколько горутин исполняют Go-код параллельно. Он не ограничивает число горутин и не ограничивает число потоков ОС на блокировках.
| Ситуация | Ограничивает | Что делать |
|---|---|---|
| CPU-bound работа не грузит все ядра | P (GOMAXPROCS) | Проверить, что GOMAXPROCS равен числу ядер контейнера, а не хоста |
| В контейнере с лимитом CPU программа плодит лишние потоки | Неверный GOMAXPROCS | Задать GOMAXPROCS по лимиту cgroup или через automaxprocs |
| Тысячи потоков ОС при умеренной нагрузке | Блокирующие syscall | Искать блокирующий I/O вне сетевого поллера, CGO-вызовы |
Отдельная ловушка — Go в контейнере с лимитом CPU. До версии 1.25 рантайм по умолчанию видел все ядра хоста и выставлял GOMAXPROCS по ним, игнорируя cgroup-лимит. Это приводило к лишним P, конкуренции и throttling. Проверяйте поведение вашей версии; классическое обходное решение — библиотека uber-go/automaxprocs, которая читает лимит cgroup.
Как посмотреть на планировщик своими глазами
Не гадайте — снимайте данные. Переменная окружения GODEBUG печатает состояние планировщика в stderr:
# раз в 1000 мс печатать сводку по планировщику
GODEBUG=schedtrace=1000 ./yourapp
# добавить детализацию по каждому P
GODEBUG=schedtrace=1000,scheddetail=1 ./yourapp
В строке трассировки вы увидите число потоков, простаивающих P, длину глобальной очереди (runqueue) и локальных очередей каждого P. Растущая глобальная очередь при простаивающих P — признак дисбаланса или частого переполнения локальных очередей.
Для более глубокого анализа снимайте трейс исполнения и смотрите его через встроенный инструмент:
package main
import (
"os"
"runtime/trace"
)
func main() {
// файл, куда пишется трейс исполнения
f, err := os.Create("trace.out")
if err != nil {
panic(err)
}
defer f.Close()
// включаем трассировку рантайма
if err := trace.Start(f); err != nil {
panic(err)
}
defer trace.Stop()
// здесь ваша нагрузка
runWorkload()
}
Открывается трейс командой go tool trace trace.out — она поднимает локальный веб-интерфейс с временной шкалой горутин, событиями планировщика и распределением по P.
Граничные случаи, где модель ведёт себя неочевидно
- CGO-вызов блокирует M так же, как длинный syscall. Обильные вызовы в C-библиотеки могут раздуть число потоков ОС. Планировщик не видит, что происходит внутри C-кода, и не может его прервать.
- runtime.LockOSThread привязывает горутину к конкретному M. Пока привязка активна, эта горутина не мигрирует. Нужно для взаимодействия с потоко-зависимыми C-библиотеками (например, некоторыми графическими), но ломает балансировку, если забыть про Unlock.
- Плотный цикл без аллокаций до Go 1.14 мог намертво занять P. Сейчас sysmon прервёт его через ~10 мс, но эта приостановка не бесплатна и не мгновенна.
- Слишком много горутин, пишущих в один канал или мьютекс, вызывают частые переключения и обращения к глобальным структурам. Профиль покажет время в
runtime.chansendилиruntime.lock— это сигнал пересмотреть архитектуру, а не крутить GOMAXPROCS.
Что делать дальше
Начните с измерения, а не с настроек. Снимите schedtrace на реальной нагрузке и посмотрите на длину глобальной очереди и число потоков. Если программа крутится в контейнере — проверьте, какое GOMAXPROCS выставил рантайм вашей версии Go, и сверьте с лимитом cgroup. При подозрении на блокировки снимите трейс через runtime/trace и найдите горутины, которые долго висят в состоянии syscall или waiting. Для более глубокого погружения читайте раздел о планировщике в исходниках рантайма (файлы runtime/proc.go и комментарий в его начале) — это основной первоисточник по теме.
Аналитик звонков: Разбор записи звонка по этапам сделки: где сорвалось, что упущено, три исправляемые рекомендации с цитатами.
Спросить за 90 ₽Источники: The Go Memory Model и runtime — исходники планировщика (runtime/proc.go), Diagnostics — официальная документация Go по трассировке и отладке
Частые вопросы
У меня другая версия Go — эта модель применима?
Сколько горутин можно запустить безопасно?
Стоит ли вручную менять GOMAXPROCS?
Почему программа держит больше потоков, чем ядер?
Что делать, если приложение не масштабируется по ядрам?
Блокирует ли сетевой запрос поток ОС?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.