Как устроен планировщик goroutine в Go и что такое M, P и G
Что проверяют
Знаете ли вы, почему тысячи goroutine работают на нескольких потоках ОС и что происходит, когда одна из них блокируется. Следом обычно спрашивают про GOMAXPROCS, work stealing и блокирующий системный вызов.
Как отвечать
- G: goroutine, структура со стеком и контекстом. Стек стартует с 2 КБ и растёт по мере нужды, поэтому их можно создавать сотнями тысяч;
- M: поток ОС, который реально исполняет код;
- P: логический процессор, право исполнять Go-код. У P есть локальная очередь на 256 goroutine и слот
runnext. Число P равноGOMAXPROCS, по умолчанию это число доступных ядер.
Чтобы исполнять Go-код, M обязан держать P, поэтому одновременно работает не больше GOMAXPROCS goroutine. Новая goroutine кладётся в локальную очередь текущего P, при переполнении половина очереди уходит в глобальную. Когда у P пусто, он проверяет глобальную очередь, netpoller и крадёт половину очереди у другого P. Раз в 61 тик планировщик заглядывает в глобальную очередь даже при непустой локальной, чтобы там никто не голодал.
Сетевой I/O поток не занимает: goroutine паркуется, netpoller на epoll или kqueue разбудит её по готовности сокета. Блокирующий системный вызов держит M, и P передаётся другому M. Фоновый поток sysmon отбирает P у зависших в syscall и вытесняет goroutine, которые работают дольше 10 мс. С Go 1.14 вытеснение асинхронное, через сигнал, так что даже цикл без вызовов функций не держит P вечно.
С Go 1.25 на Linux GOMAXPROCS по умолчанию учитывает лимит CPU из cgroup. На старых версиях рантайм в контейнере видел все ядра хоста, выручала библиотека automaxprocs.
Где спотыкаются
Советуют поднять GOMAXPROCS ради множества соединений. Сетевой код ждёт I/O, а не CPU, лишние P добавят только переключения.
Путают M и P или говорят, что goroutine привязана к потоку. Она переезжает между M в любой момент.
Считают, что syscall останавливает весь P. Рантайм отдаёт P другому потоку, а число M растёт до лимита debug.SetMaxThreads, по умолчанию 10 000.