golang

Представляем планировщик Go: Вы никогда не смотрели на горутины с этой стороны

  • понедельник, 1 апреля 2024 г. в 00:00:06
https://habr.com/ru/articles/804145/
За кулисами рантайма Go
За кулисами рантайма Go

Не переживайте о понимании изображения выше прямо сейчас, мы начнем с самых основ.

Горутины раcпределяются по потокам, которыми Планировщик Go управляет под капотом. Мы знаем о горутинах следующее:

  • Горутины, если говорить о скорости исполнения, не обязательно быстрее чем потоки, так как они нуждаются в потоках, чтобы ими исполняться.

  • Ключевое преимущество горутин — в таких нюансах как контекст свитчинг, размере занимаемой ими памяти и стоимости создания и «удаления».

Вероятно, вы слышали о планировщике Go и раньше, но насколько хорошо мы знаем о том как он работает? Как он связывает горутины с потоками?

Разберем по очереди операции, которые выполняет планировщик.

1. Планировщик M:N

Команда Go упростила конкуретность для нас. Просто подумайте о том, что для того чтобы создать горутину, достаточно всего лишь добавить префикс go к вызову функции.

go doWork()

Но за этим простым шагом стоит куда более сложная система.

Go просто не дает нам доступа к потокам. Потоками управляет планировщик, являющийся ключевой частью рантайма Go.

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

Так что с M:N?

Это значит, что роль планировщика Go в том, чтобы привязать M горутин к N потоков ядра, формируя модель M:N. У вас может быть как больше потоков ОС, чем ядер, так и больше горутин, чем потоков.

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

  • Конкурентность: Это о том чтобы несколько задач исполнялись одновременно, но не обязательно в один и тот же момент времени.

  • Параллельность: Это значит, что несколько задач исполняются в один и тот же момент времени, скорее всего используя при этом больше одного ядра ЦП.

Конкурентность vs. Параллелизм
Конкурентность vs. Параллелизм

Давайте посмотрим как планировщик Go управляет потоками.

2. PMG модель

Прежде чем мы распутаем внутреннее устройство, давайте разберемся что за P, M и G.

G (горутина)

Горутина — это самый маленький юнит исполнения в Go, работа которого похожа на легковесный поток.

Вместе с рантаймом Go, горутина представляет из себя структуру g. Как только она вызывается, она находит свое место в локальной очереди исполнение логического процессора P. P, в свою очередь, передает ее на исполнение потоку ОС (M).

Горутина может находиться в трех (основных) состояниях:

  • Waiting: В этом состоянии, горутина бездействует. Например встает на паузу для операции с каналами или блокировками, либо может быть остановлена системным вызовом.

  • Runnable: Горутина готова к тому чтобы быть исполненной, но еще не исполняется. Она ожидает своей очереди на потоке (M).

  • Running: Горутина исполняется на потоке (M). Это будет продолжаться, пока работа не будет выполнена, или до тех пор, пока ее не прервет планировщик, либо что‑то еще ее не заблокирует.

Состояния горутин
Состояния горутин

Горутины НЕ используются единожды и потом выбрасываются.

Когда новая горутина инициируется, планировщик Go обращается к пулу горутин чтобы забрать одну, и если ни одной нет, то создает новую. Эта новая горутина добавляется в исполняемую очередь процесора (P).

P (Логический процессор)

Вместе с планировщиком Go, когда мы говорим «процессор», мы имеем ввиду логическую сущность, а не физический процессор.

Тем не менее, по умолчанию количество P устанавливается равному количеству доступных ядер на хосте, вы можете изменить это значение, используя runtime.GOMAXPROCS(int).

runtime.GOMAXPROCS(0) // получить доступное количество логических ядер на хосте
// Output: 8 (зависит от процессора)

Если вы планируете менять это значение, лучше всего сделать это единожды на старте приложения. Если менять его в рантайме, это приведет к STW (stopTheWorld), все приложение встанет на паузу, пока изменяется количество процессоров.

Каждый из P содержит собственный список runnable горутин, который называется Локальная Очередь Исполнения (Local Run Queue), размер которой может составить до 256 горутин.

Планировщик -> Логический Процессор P
Планировщик → Логический Процессор P

Если очередь P достигает максимального количества (256) и переполняется, существует общая очередь Global Run Queue (Глобальная Очередь Исполнения), но мы вернемся к ней чуть позже.

«Так о чем нам говорит количество P?»

Количество P говорит нам о том, сколько горутин может исполняться конкурентно.

M (Поток ОС)

Обычное Go приложение может использовать до 10 000 потоков.

И да, речь именно о потоках, а не о горутинах. Если вы выйдете за рамки этого лимита, есть риск того, что приложение крашится.

«Когда создается поток?»

Подумайте о такой ситуации: горутина находится в состоянии runnable и ей требуется поток.

Что произойдет, если все потоки уже заблокированы, возможно системными вызовами или невытесняемыми операциями? В этом случае в игру вступает планировщик и создает новый поток для этой горутины.

(Нужно отметить, что если поток просто занят сложным вычислением или задачей с долгим временем выполнения, это не значит что он будет расценен как заблокированный)

Если вы хотите изменить лимит поток по умолчанию, можно воспользоваться функцией runtime/debug.SetMaxThreads() . Она позволит установить максимальное количество потоков ОС, которое сможет использовать приложение.

Так же, стоит помнить о том, что потоки переиспользуются, так как создание и удаление потока — ресурсоемкие операции.

3. Как работает MPG

Давайте шаг за шагом разберемся как M, P и G работают вместе.

Я не буду вдаваться во все мелкие детали, в общих чертах это работает так:

Как работает планировщик Go
Как работает планировщик Go
  1. Инициирование горутины: используя go func(), рантайм Go создает новую горутину, или использует уже существующую из пула.

  2. Позиционирование в очереди: горутина ищет место в локальных очередях логических процессоров (P), и если они все переполнены, она помещается в глобальную очередь.

  3. Связывание с потоком: в этом шаге поток (M) вступает в игру. Поток берет P и начинает исполнять горутины из его локальной очереди. Как только поток приступает к исполнению горутины, процессор (P), в очереди которого находилась горутина, ассоциируется с этим потоком (M) и становится недоступен для других потоков.

  4. «Заимствование работы» (work stealing): если локальная очередь процессора (P) опустела, поток M пытается позаимствовать половину runnable горутин из локальной очереди другого процессора (P). Если ничего не нашлось, поток (M) проверяет глобальную очередь и затем Net Poller (ниже есть диаграмма процесса work stealing).

  5. Аллокация ресурсов: после того как, поток M выберет горутину G, он обеспечивает ее всеми необходимыми ресурсами.

«А что насчет заблокированных потоков?»

Если горутина совершает системный вызов, который займет много времени (например чтение файла), поток M будет ожидать.

Но планировщик не устраивают те, кто просто сидит и ждет. Он отвязывает занятый поток M от его процессора P, и связывает другую runnable горутину из очереди P к новому или существующему свободному потоку M, который ассоциируется с этим процессором.

Заблокированные потоки
Заблокированные потоки

Процесс заимствования (work stealing)

Когда поток M исполнил все свои задания и ему больше нечего делать, это не значит что он будет простаивать.

Поток будет искать горутины в локальных очередях других логических процессоров и заберет половину их горутин:

Процесс заимствования
Процесс заимствования
  1. Каждый 61 тик, поток M проверяет глобальную очередь, и если в ней находится runnable горутина, work stealing останавливается.

  2. Поток M проверяет наличие runnable горутин в локальной очереди, связанной с его процессором P.

  3. Если поток ничего не находит в локальной очереди, он еще раз проверяет глобальную очередь.

  4. Поток проверяет network poller на наличие ожидающих горутин, работающих с сетью.

  5. Если поток все еще не нашел никаких задач после проверки network poller»а, он перейдет в активный цикличный режим поиска.

  6. В этом состоянии поток будет пытаться позаимствовать горутины из локальных очередей других процессоров.

  7. После всех этих шагов, если поток все еще не нашел себе работу, он останавливает активный поиск.

  8. Теперь, если появятся новые горутины и будет свободный процессор без ассоциированного с ним потока, другой поток сможет быть привлечен для работы.

Обратите внимание, что глобальная очередь проверяется дважды: раз в 61 тик и в случае если локальная очередь оказалась пустой.

«Если M ассоциирован с его P, как он может забрать горутины из другого процессора? Поток меняет один процессор на другой?»

Ответ — нет.

Даже если поток M забирает горутины из несвязанного с ним процессора P, он будет исполнять эти горутины используя их процессор. Таким образом, пока поток исполняет чужие горутины, он не теряет связь со своим собственным процессором.

«Почему 61?»

При разработке алгоритмов, и особенно алгоритмов хеширования, есть практика использовать простые числа, делящиеся только на 1 и на самих себя.

Это может снизить шанс возникновения закономерностей, которые в итоге приводят к коллизиям или другому нежелательному поведению.

Если делать проверку чаще, на нее может уходить слишком много ресурсов, если реже — горутины могут ожидать выполнения слишком долго.

Network Poller

Мы не раскрывали тему network poller»а, но упоминали его в диаграмме work stealing.

Как и планировщик Go, Network Poller — компонент рантайма Go и служит для исполнения запросов связанных с сетью, например сетевые операции I/O.

Есть 2 типа системных вызовов:

  • Системные вызовы связанные с сетью: когда горутина выполняет сетевую операцию I/O, вместо того чтобы блокировать поток, она добавляется в network poller. Netpoller ожидает асинхронного выполнения операции, и когда дожидается — горутина снова становится runnable, и ее исполнение становится доступно для потока.

  • Другие системные вызовы: если они потенциально блокирующие и не будут исполнены network poller»ом, горутина полностью займет поток ОС, этот поток будет заблокирован и рантайм Go будет исполнять остальные горутины на других свободных потоках.

прим. пер. Network poller — юнит рантайма Go, работающий с реализацией мультиплексирования операций сетевых I/O. Например в линукс эту концепцию реализует инструмент epoll.