сеньорчикОткрыть в Telegram
← вся теориятеория к собесу · Ошибки и рантайм Go

Планировщик Go: модель GMP

Планировщик GMP

Сто тысяч горутин на двух ядрах едут и не спотыкаются. Спрашивают, за счёт чего: кто раскладывает горутины по потокам, что задаёт GOMAXPROCS и что случается, когда горутина уходит в блокирующий вызов.

Стержень: G - горутина, M - поток операционной системы, P - логический процессор со своей очередью; код исполняется только на связке M и P.

// Формулировки: «расскажи про GMP», «что задаёт GOMAXPROCS?», «что при блокирующем системном вызове?»

Три буквы, если запустить и посмотреть

Запустил в контейнере с двумя ядрами программу из двух строк: печатает runtime.NumCPU() и runtime.GOMAXPROCS(0). Обе выдали 2. Вот эта двойка и есть число P.

P - логический процессор: контекст с локальной очередью горутин и правом исполнять код. M - реальный поток операционной системы, тот самый, который раскладывает по ядрам уже ядро Linux. G - горутина: её стек, её состояние, её место в очереди. Горутина работает только когда собралась связка: поток взял логический процессор и крутит на нём горутину.

// Число P ограничивает параллелизм и больше ничего. Горутин при этом бывают сотни тысяч, а потоков - десятки: ни те, ни другие к GOMAXPROCS не привязаны.

GMP
горутина, поток операционной системы, логический процессор
GOMAXPROCS
число P, то есть потолок параллелизма

Очередь у каждого своя, а лентяй ворует

Картинка: у каждого логического процессора своя стопка карточек с задачами. Своя - потому что общую пришлось бы брать под блокировкой, а переключений в секунду там миллионы.

Кончились карточки у одного - он идёт к случайному соседу и забирает половину его стопки. В исходниках рантайма это буквально строка n = n - n/2 внутри runqsteal в proc.go. Нагрузка выравнивается сама, без начальника, который раздаёт задачи.

// Чтобы задачи из общей очереди не голодали, каждый 61-й тик планировщик заглядывает туда принудительно: в proc.go это условие pp.schedtick%61 == 0. Число выглядит случайным, но без него две горутины, бесконечно порождающие друг друга, заняли бы локальную очередь навсегда.

work stealing
простаивающий P забирает половину очереди соседа
каждый 61-й тик
принудительный заход в общую очередь ради честности

Блокирующий вызов и сетевое ожидание - разные вещи

Замерил на той же двухъядерной машине. Опыт первый: 30 горутин читают из пустого канала операционной системы (pipe без писателя) - настоящий блокирующий вызов. Потоков в процессе было 4, стало 33. Тридцать горутин утащили с собой тридцать потоков, хотя P всего два.

Опыт второй: 200 TCP-соединений, которые молчат, на каждом горутина в c.Read. Горутин 202, потоков создано 6 - ровно столько же, сколько до соединений. Сеть в Go идёт через netpoller поверх epoll: горутину снимают с исполнения, поток уходит работать дальше, а когда сокет готов, опросчик кладёт горутину обратно в очередь.

// Отсюда и «Go держит десятки тысяч соединений»: держит их опросчик, а не потоки. Файловый ввод-вывод и вызовы в C через опросчик не идут, там потоки тратятся по-настоящему - как в моём первом опыте.

netpoller
опросчик готовности сокетов, потока не занимает
блокирующий системный вызов
держит поток, но освобождает P соседям

Как отвечать: «Как устроен планировщик Go и что задаёт GOMAXPROCS?»

Модель называется GMP. G - горутина со своим стеком и состоянием, M - реальный поток операционной системы, P - логический процессор, то есть контекст с локальной очередью горутин и правом исполнять код. Работает горутина только на связке потока с логическим процессором. GOMAXPROCS задаёт число P, по умолчанию равное числу доступных ядер, и ограничивает оно параллелизм - не количество горутин и не количество потоков. Разница заметна на замере: я держал 30 горутин в блокирующих чтениях при GOMAXPROCS равном двум, и рантайм завёл 33 потока. Очереди у каждого P свои, потому что общая потребовала бы блокировки на каждом переключении; когда очередь пустеет, логический процессор крадёт половину задач у случайного соседа - так нагрузка выравнивается без центрального распределителя. Отдельно про ожидание: сетевое идёт через netpoller поверх epoll и потоков не занимает вовсе. У меня 200 молчащих соединений жили на том же числе потоков, что и пустая программа. И с версии 1.14 планировщик вытесняет горутины асинхронно, по сигналу, поэтому счётный цикл без вызовов функций больше не подвешивает поток и не мешает сборщику.

Сильный ответ: модель названа с ролью каждой буквы, GOMAXPROCS привязан к параллелизму, а не к горутинам, кража работы объяснена через локальность очередей, разведены блокирующий вызов и сетевое ожидание, добавлено асинхронное вытеснение. Числа из собственного замера снимают у собеседующего половину уточняющих вопросов.

На чём валят

  • Считают, что GOMAXPROCS ограничивает число горутин. Он ограничивает параллелизм.
  • Уверены, что потоков в процессе ровно GOMAXPROCS: у меня 30 блокирующих чтений подняли счётчик потоков с 4 до 33.
  • Валят в одну кучу блокирующий системный вызов и сетевое ожидание. Второе потоков не занимает.
  • Ищут центрального распределителя задач. Очереди локальные, пустой P сам крадёт половину у соседа.
  • Пересказывают старую байку про счётный цикл, вешающий поток: с версии 1.14 горутину вытесняют сигналом.

Проверьте себя

Пять вопросов из банка по этой подтеме. Всего их 7, остальные разбираются в тренажёре.

  1. #go_scheduler1 / 5
    На какой модели построен планировщик горутин в Go?
    A)G-M-P: горутины (G) на потоки ОС (M) через процессоры (P)
    B)Один поток ОС на каждую горутину, строго один к одному
    C)Кооперативная модель без потоков ОС — всё в одном системном потоке
    D)Отдельный процесс ОС на каждую горутину с общей памятью
    показать ответ и разбор
    +A)G-M-P: горутины (G) на потоки ОС (M) через процессоры (P)

    // разбор: Планировщик Go — модель G-M-P: G (горутина), M (machine, поток ОС), P (processor, логический процессор с локальной очередью горутин). M исполняет горутины из очереди своего P; число P задаёт GOMAXPROCS. Так рантайм мультиплексирует тысячи G на несколько M (M:N), переключая их в user space, без ухода в ядро.

  2. #go_scheduler2 / 5
    Что задаёт GOMAXPROCS?
    A)Максимальное число горутин, которые можно создать одновременно
    B)Размер стека каждой горутины в килобайтах
    C)Сколько потоков ОС могут одновременно исполнять Go-код
    D)Число ядер, которые ОС выделит процессу под нагрузкой
    показать ответ и разбор
    +C)Сколько потоков ОС могут одновременно исполнять Go-код

    // разбор: GOMAXPROCS — число P, то есть сколько потоков ОС могут ОДНОВРЕМЕННО выполнять Go-код (степень параллелизма). По умолчанию равен числу доступных ядер. Он не ограничивает число горутин (их могут быть сотни тысяч поверх) и не задаёт размер стека. Горутина на блокирующем syscall не держит P — рантайм отдаёт его другим.

  3. #go_scheduler3 / 5
    Горутина ушла в блокирующий системный вызов. Что планировщик делает с её P?
    A)Блокирует весь P до возврата из системного вызова вместе с горутиной
    B)Отдаёт P другому потоку, чтобы тот исполнял остальные горутины
    C)Завершает горутину по таймауту, освобождая поток
    D)Переносит остальные горутины этого P на процессы ОС
    показать ответ и разбор
    +B)Отдаёт P другому потоку, чтобы тот исполнял остальные горутины

    // разбор: Когда M застревает на блокирующем syscall, рантайм отвязывает его P и отдаёт другому (при нужде — новому) M, чтобы очередь горутин этого P исполнялась дальше и ядро не простаивало. Вернувшийся из syscall M попробует снова взять P или припаркуется. Плюс work stealing: P с пустой очередью крадёт горутины у других — так балансируется нагрузка.

  4. #go_scheduler4 / 5
    Что делает планировщик, когда локальная очередь одного P опустела?
    A)Ждёт, пока другие P сами передадут ему часть своих горутин
    B)Переносит все горутины в общую глобальную очередь и берёт оттуда
    C)Останавливает поток и возвращает его операционной системе
    D)Забирает часть горутин из очереди другого P — это work stealing
    показать ответ и разбор
    +D)Забирает часть горутин из очереди другого P — это work stealing

    // разбор: У каждого P своя локальная очередь — это дешевле общей с блокировкой. Опустела — P идёт красть примерно половину задач у случайного соседа, а заодно поглядывает в глобальную очередь и в сетевой опросчик. Так нагрузка выравнивается сама, без центрального распределителя: простаивающий процессор находит работу, а не ждёт раздачи.

  5. #go_scheduler5 / 5
    Горутина крутится в цикле без вызовов функций и обращений к каналам. Заблокирует ли она свой поток навсегда?
    A)Да: без точки переключения планировщик отобрать управление не может
    B)Да, если GOMAXPROCS равен единице, иначе её вытеснит соседний P
    C)Нет: с версии 1.14 планировщик вытесняет горутины асинхронно, по сигналу
    D)Нет: рантайм разбивает длинные циклы на части при компиляции
    показать ответ и разбор
    +C)Нет: с версии 1.14 планировщик вытесняет горутины асинхронно, по сигналу

    // разбор: До 1.14 планирование было кооперативным: переключение случалось лишь в точках вроде вызова функции или работы с каналом, и плотный счётный цикл действительно вешал поток — от него страдал и сборщик мусора, которому нужна остановка мира. Теперь рантайм посылает потоку сигнал и вытесняет горутину принудительно, так что зависание из-за такого цикла ушло в историю.

дальше

Теорию прочитали. Навык ставится повторением

В Сеньорчике эта подтема идёт в ежедневных сессиях: движок возвращает её, пока ответы не станут уверенными, и ведёт прогресс отдельно по каждой подтеме. Теория внутри тоже бесплатна, лимит только на количество вопросов в день.