Планирование в Go другой взляд
Возможно, вы слышали о планировщике Goroutine раньше, но насколько хорошо мы на самом деле знаем, как он работает? Как он связывает goroutines с потоками?
О чем будем говорить: перевод статьи
- Статья обсуждает параллелизм и распараллеливаемость в программировании на языке Go.
- Go предоставляет планировщик Goroutine для упрощения параллелизма.
- Goroutine действует как самая маленькая исполнительная единица Go и сопоставляется с потоками ядра.
- Программы Goroutines существуют в трех основных состояниях: ожидание, работоспособное и запуск.
- Планировщик Go сопоставляет M подпрограмм с N потоками ядра, формируя модель M:N.
- Параллелизм означает одновременную обработку множества задач, не всегда одновременно.
- Распараллеливаемость означает выполнение многих задач одновременно, часто с использованием более одного ядра процессора.
- Планировщик Go работает с потоками, используя логические объекты вместо физических.
- Ограничение потоков по умолчанию в Go составляет до 10 000 потоков.
Поехали
Серия параллелизмов
Goroutines 102: базовое пошаговое руководство
Объясненные каналы Go: больше, чем просто руководство для начинающих.
Выберите & для канала диапазона в Go: разбивка
Раскрыт планировщик Goroutine: больше никогда не увидите Goroutines в прежнем виде
Не беспокойтесь о понимании изображения выше прямо сейчас, поскольку мы собираемся начать с самых основ.
Goroutines распределяются по потокам, которые планировщик Goroutine обрабатывает за сценой. Из наших предыдущих выступлений мы кое-что знаем о goroutines:
Goroutines с точки зрения необработанной скорости выполнения не обязательно быстрее потоков, поскольку для их запуска требуется реальный поток.
Реальное преимущество goroutines заключается в таких областях, как переключение контекста, объем памяти, стоимость создания и демонтажа.
Возможно, вы слышали о планировщике Goroutine раньше, но насколько хорошо мы на самом деле знаем, как он работает? Как он связывает goroutines с потоками?
Теперь работа планировщика разбивается шаг за шагом.
1. Планировщик Goroutine M: N
Команда Go действительно упростила для нас параллелизм, просто подумайте об этом: создать goroutine так же просто, как добавить к функции префикс с go ключевым словом.
1 | go doWork() |
Но за этим простым шагом стоит более глубокая система работы.
С самого начала Go не просто предоставлял нам потоки. Вместо этого в середине есть помощник, планировщик Goroutine, который является ключевой частью среды выполнения Go.
Так что же это за метка M: N?
Это указывает на роль планировщика Go в сопоставлении M goroutines с N потоками ядра, формируя модель M: N. У вас может быть больше потоков операционной системы, чем ядер, точно так же, как goroutines может быть больше, чем потоков операционной системы.
Прежде чем мы углубимся в изучение планировщика, давайте проясним два термина, которые часто путают: параллелизм и параллелизм.
Конкурентный параллелизм(Concurrency): речь идет о одновременной обработке множества задач, все они перемещаются, но не всегда в одно и то же время.
Параллелизм(Parallelism): это означает, что многие задачи выполняются в одно и то же время, часто с использованием более чем одного ядра процессора.
2. Модель PMG
Прежде чем мы распутаем внутреннюю работу, давайте разберем, что означают P, M и G .
G (goroutine)
Goroutine действует как наименьшая исполнительная единица Go, сродни облегченному потоку.
Во время выполнения Go это представлено struct{} с именем g. После установки он находит свое место в локальной очереди выполнения логического процессора P (или очереди Gs), и оттуда P передает его фактическому потоку ядра (M).
Goroutines обычно существуют в трех основных состояниях:
Ожидание(Waiting): На данном этапе goroutine находится в состоянии покоя, возможно, он приостановлен для выполнения такой операции, как канал или блокировка, или, возможно, он остановлен системным вызовом.
Возможность выполнения или Готовый к выполнению (Runnable): goroutine полностью готова к запуску, но еще не запущена, она ожидает своей очереди для запуска в потоке (M).
Запущеная горутина (Running or Executing): теперь goroutine активно выполняется в потоке (M). Это продолжается до тех пор, пока его задача не будет выполнена, если только планировщик не прервет это или что-то еще не преградит ему путь
Goroutines НЕ используются только один раз, а затем отбрасываются.
Вместо этого, когда запускается новая goroutine, среда выполнения Go погружается в пул goroutine, чтобы выбрать одну, но если ничего не найдено, она создает новую. Затем эта новая goroutine присоединяется к очереди выполнения P.
P (логический процессор)
В планировщике Go, когда мы упоминаем “процессор”, мы имеем в виду логическую сущность, а не физическую.
По умолчанию число P равно количеству доступных ядер, вы можете проверить или изменить количество этих процессоров с помощью runtime.GOMAXPROCS(int).
1 | runtime.GOMAXPROCS(0) // get the current allowed number of logical processors |
Если вы подумаете об изменении этого, лучше всего сделать это один раз при запуске вашего приложения, если вы измените его во время выполнения, это вызовет STW (stopTheWorld), все приостановлено до изменения размера процессора.
Каждый P содержит свой собственный список запускаемых goroutines, называемый локальной очередью запуска, который может вместить до 256 goroutines.
Если в очереди P достигнуто максимальное количество goroutines (256), появляется общая очередь, называемая глобальной очередью запуска, но мы вернемся к этому позже.
“Итак, что на самом деле показывает это число ‘P”?”
Это указывает на количество goroutines, которые могут работать одновременно — представьте, что они работают бок о бок.
M (Машинный поток — поток операционной системы)
Типичная программа Go способна использовать до 10 000 потоков.
И да, я говорю о потоках, а не о goroutines. Если вы превысите это ограничение, вы рискуете завершить работу своего приложения Go.
“Когда создается поток?”
Подумайте об этой ситуации: goroutine находится в работоспособном состоянии и требует потока.
Что произойдет, если все потоки уже заблокированы, возможно, из-за системных вызовов или операций без вытеснения? В этом случае вмешивается планировщик и создает новый поток для этой goroutine.
(Следует отметить одну вещь: если поток просто занят дорогостоящими вычислениями или длительно выполняющейся задачей, это не считается зависанием или блокировкой)
Если вы хотите изменить ограничение потока по умолчанию, вы можете использовать runtime/debug.SetMaxThreads() функцию, это позволяет вам установить максимальное количество потоков операционной системы, которое может использовать ваша программа Go.
Также полезно знать, что потоки используются повторно, поскольку создание или удаление потоков требует больших ресурсов.
3. Как работает MPG
Давайте разберемся, как M, P и G работают вместе, шаг за шагом, с помощью маркированных пунктов.
Я не буду здесь вдаваться в каждую мельчайшую деталь, но углублюсь в следующих историях. Если это вас заинтересовало, пожалуйста, подпишитесь.
Инициируйте goroutine(Initiate a goroutine): с помощью go func() команды среда выполнения Go либо создает новую goroutine, либо выбирает существующую из пула.
Расположение очереди(Queue positioning): goroutine ищет свое место в очереди, и если локальные очереди всех логических процессоров (P) заполнены, эта goroutine помещается в глобальную очередь.
Сопряжение потоков(Thread-Pairing): здесь в игру вступает M. Он захватывает(grabs) P и начинает обработку goroutine из локальной очереди P, поскольку M взаимодействует с этой goroutine, связанный с ней P становится занятым и недоступным для других Ms.
Акт кражи(The act of stealing): если очередь P исчерпана, M пытается “позаимствовать(borrow)” половину запущенных goroutines из очереди другого P . В случае неудачи он проверяет глобальную очередь, за которой следует сетевой опросник (посмотрите на раздел схемы процесса кражи ниже).
Распределение ресурсов(Resource allocation): После того, как M выбирает goroutine (G), он обеспечивает все необходимые ресурсы для запуска G.
“Как насчет того, что поток заблокирован?”
Если goroutine запускает системный вызов, который требует времени (например, чтение файла), M ожидает.
Но планировщику не нравится, когда кто-то просто сидит и ждет, он отсоединяет остановленную M от ее P и подключает другую, работоспособную (runable т.е готовую к выполнению) goroutine из очереди к новой или существующей M, которая затем объединяется с P
Процесс кражи (Stealing Process)
Когда поток (M) завершает свои задачи и ему больше нечего делать, он не сидит сложа руки.
Вместо этого он активно ищет больше работы, просматривая другие процессоры и беря на себя половину их задач, давайте разберем это:
Каждые 61 тик M проверяет глобальную очередь выполнения, чтобы убедиться в честности( fairness - справведлиновсть) выполнения. Если в глобальной очереди найдена работоспособная(runnable - готовая к выполнению) goroutine, остановитесь.
Этот поток M теперь проверяет свою локальную очередь запуска, связанную с его процессором P, на наличие любых запускаемых(runable - готовых к выполнению) goroutines для работы.
Если поток обнаруживает, что его очередь(локальная) пуста, он затем просматривает глобальную очередь, чтобы увидеть, есть ли там какие-либо задачи(any tasks waiting), ожидающие своего завершения.
Затем поток проверяет с помощью сетевого опроса(network poller(NP) т.е поток обращается к NP) наличие любых заданий(any network-related jobs), связанных с сетью.
Если поток по-прежнему не нашел никаких задач после проверки сетевого опроса(NP), он переходит в режим активного поиска(active search mode), который мы можем рассматривать как состояние вращения(spinning state).
В этом состоянии поток пытается “позаимствовать - borrow” задачи из очередей других процессоров.
После всех этих шагов, если поток по-прежнему не нашел никакой работы, он прекращает активный поиск.
Теперь, если поступают новые задачи и есть свободный процессор без каких-либо поисковых потоков, другому потоку может быть предложено начать работу.
Следует отметить, что глобальная очередь фактически проверяется дважды: один раз через каждые 61 тик для проверки достоверности и еще раз, если локальная очередь пуста.
- “Если M привязан к своему P, как он может принимать задачи от других процессоров? Меняет ли M свой P?”
Ответ - нет.
Даже если M берет задачу из очереди другого P, она выполняет эту задачу с использованием своего исходного процессора. Таким образом, пока M берет на себя новые задачи, он остается верным своему процессору.
- “Почему 61?”
При разработке алгоритмов, особенно алгоритмов хеширования, простые числа часто выбираются из-за отсутствия у них делителей, отличных от 1, и самих себя.
Это может снизить вероятность появления шаблонов или закономерностей, которые могут привести к “коллизиям” или другому нежелательному поведению.
Если они слишком короткие, система может тратить ресурсы на частую проверку глобальной очереди запуска. Если слишком длинные, программы goroutine могут чрезмерно долго ждать выполнения.
Сетевой опросник (Network Poller)
Мы мало обсуждали этот сетевой опросник, но он представлен на схеме процесса кражи.
Как и планировщик Go, сетевой опросник является компонентом среды выполнения Go и отвечает за обработку вызовов, связанных с сетью (например, сетевого ввода-вывода).
Давайте сравним 2 типа системных вызовов:
Системные вызовы, связанные с сетью (Network-related Syscalls): когда goroutine выполняет операцию сетевого ввода-вывода(network I/O operation), вместо блокирования потока она регистрируется в сетевом опроснике(NP). NP ожидает асинхронного завершения операции, и после завершения goroutine снова становится работоспособной(runnable готовый к выполнению) и может продолжить свое выполнение в потоке.
Другие системные вызовы (Other Syscalls): если они потенциально блокируются и не обрабатываются сетевым опросником, они могут заставить goroutine перенести свое выполнение в поток операционной системы. Блокируется только этот конкретный поток операционной системы, и планировщик Go runtime scheduler может выполнять другие goroutines в разных потоках.
Кстати нашел еще один перевод на хабре автор перевел и картинки