Планировщик
Last updated
Was this helpful?
Last updated
Was this helpful?
Планировщики операционной системы являются сложным программным обеспечением. Они должны учитывать расположение и настройку оборудования, на котором они работают. Это включает в себя, помимо прочего, наличие нескольких процессоров и ядер, кэшей ЦП и . Без этого знания планировщик не может быть настолько эффективным, насколько это возможно. Ваша программа — это просто набор машинных инструкций, которые должны выполняться последовательно один за другим. Чтобы это произошло, операционная система использует концепцию потока. Задачей потока является учет и последовательное выполнение назначенного ему набора инструкций. Выполнение продолжается до тех пор, пока не останется больше инструкций для выполнения потоком. Вот почему я называю поток, «путь исполнения». Каждая программа, которую вы запускаете, создает процесс, а каждому процессу — начальный поток. Потоки имеют возможность создавать больше потоков. Все эти разные потоки работают независимо друг от друга, и решения по планированию принимаются на уровне потоков, а не на уровне процессов. Потоки могут работать одновременно (каждый по очереди на отдельном ядре) или параллельно (каждый работает одновременно на разных ядрах). Потоки также поддерживают свое собственное состояние, чтобы обеспечить безопасное, локальное и независимое выполнение их инструкций. Планировщик ОС отвечает за то, чтобы ядра не простаивали, если есть потоки, которые могут выполняться. Это также должно создать иллюзию того, что все потоки, которые могут выполняться, выполняются одновременно. В процессе создания этой иллюзии планировщик должен запускать потоки с более высоким приоритетом по сравнению с потоками с более низким приоритетом. Тем не менее, потоки с более низким приоритетом не могут быть лишены времени выполнения. Планировщик также должен максимально сократить задержки при планировании, принимая быстрые и умные решения. Чтобы лучше понять все это, полезно описать и определить несколько важных понятий.
, также (instruction pointer) (IP) позволяет потоку отслеживать следующую команду для выполнения. В большинстве процессоров IP указывает на следующую инструкцию, а не на текущую. Если вы когда-либо видели трассировку стека из программы Go, вы могли заметить эти маленькие шестнадцатеричные числа в конце каждой строки. Смотрите + 0x39 и + 0x72 в листинге 1.
Эти числа представляют смещение значения IP от вершины соответствующей функции. Значение смещения + 0x39 для IP представляет следующую инструкцию, которую поток выполнил бы внутри примерной функции, если бы программа не запаниковала. Значение смещения IP 0 + x72 является следующей инструкцией внутри основной функции, если управление вернулось к этой функции. Что еще более важно, инструкция перед указателем сообщает вам, какая инструкция выполнялась. Посмотрите на программу ниже в листинге 2, которая вызвала трассировку стека из листинга 1.
Шестнадцатеричное число + 0x39 представляет смещение IP для инструкции внутри примерной функции, которая на 57 (основание 10) байтов ниже начальной инструкции для функции. В листинге 3 ниже вы можете увидеть objdump примера функции из двоичного файла. Найдите 12-ю инструкцию, которая указана внизу. Обратите внимание на строку кода над этой инструкцией — вызов паники.
Помните: IP указывает на следующую инструкцию, а не текущую. В листинге 3 приведен хороший пример инструкций на основе amd64, которые поток для этой программы Go выполняет последовательно.
Другим важным понятием является состояние потока. Поток может находиться в одном из трех состояний: Ожидание, Готовность или Выполнение. Ожидание: это означает, что поток остановлен и ожидает чего-то для продолжения. Это может происходить по таким причинам, как ожидание аппаратного обеспечения (диск, сеть), операционной системы (системные вызовы) или вызовов синхронизации (атомарные, мьютексы). Эти типы задержек являются основной причиной плохой производительности. Готовность: это означает, что поток требует времени на ядре, чтобы он мог выполнять назначенные ему машинные инструкции. Если у вас много потоков, которым нужно время, потоки должны ждать дольше, чтобы получить время. Кроме того, сокращается отдельное количество времени, которое получает каждый поток, поскольку большее количество потоков конкурирует за время. Этот тип задержки планирования также может быть причиной плохой производительности. Выполнение: Это означает, что поток размещен на ядре и выполняет свои машинные инструкции.
Поток может выполнять два типа работ. Первый называется CPU-Bound, а второй — IO-Bound. CPU-Bound: это работа, которая никогда не создает ситуацию, когда поток может быть переведен в состояние ожидания. Это работа, которая постоянно делает расчеты. Поток, вычисляющий число Pi до N-й цифры, будет привязан к ЦП. IO-Bound: это работа, которая заставляет потоки переходить в состояние ожидания. Это работа, которая заключается в запросе доступа к ресурсу по сети или выполнении системных вызовов в операционной системе. Поток, которому необходим доступ к базе данных, будет IO-Bound. Я бы включил события синхронизации (мьютексы, атомарные), которые заставляют поток ждать как часть этой категории.
Если вы работаете в Linux, Mac или Windows, вы работаете в операционной системе с вытесняющим планировщиком.
Невытесняющие (non-preemptive) алгоритмы основаны на том, что активному потоку позволяется выполняться, пока он сам, по собственной инициативе, не отдаст управление операционной системе для того, чтобы та выбрала из очереди другой готовый к выполнению поток. Вытесняющие (preemptive) алгоритмы — это такие способы планирования потоков, в которых решение о переключении процессора с выполнения одного потока на выполнение другого потока принимается операционной системой, а не активной задачей.
Ранее, когда у процессоров было только одно ядро, планирование не было слишком сложным. Поскольку у вас был один процессор с одним ядром, только один поток мог выполняться в любой момент времени. Идея состояла в том, чтобы определить период планировщика и попытаться выполнить все потоки в состоянии готовности, в течение этого периода времени. Нет проблем: возьмите период планирования и разделите его на количество потоков, которые необходимо выполнить. Например, если вы определили период вашего планировщика равным 10 мс (миллисекунд), и у вас есть 2 потока, то каждый поток получает 5 мс каждый. Если у вас есть 5 потоков, каждый поток получает 2 мс. Однако что происходит, когда у вас есть 100 потоков? Предоставление каждому потоку отрезка времени в 10 мкс (микросекунд) не работает, потому что вы потратите значительное количество времени на ПК. Что вам нужно, так это ограничить, насколько короткими могут быть временные срезы. В последнем сценарии, если минимальный интервал времени составлял 2 мс, а у вас 100 потоков, период планировщика необходимо увеличить до 2000 мс или 2 сек. Что, если было 1000 потоков, теперь вы смотрите на период планировщика 20 сек. В этом простом примере все потоки запускаются один раз в течение 20 сек, если каждый поток использует свой временной интервал. Имейте в виду, что это очень простой взгляд на эти проблемы. Планировщик должен принимать во внимание большее количество проблем. Вы контролируете количество потоков, которые вы используете в своем приложении. Когда нужно рассмотреть больше потоков и происходит работа, связанная с IO, возникает еще больше хаоса и не детерминированного поведения. Решение проблем занимают больше времени, чтобы запланировать и выполнить. Вот почему правило «Меньше значит больше». Меньше потоков в состоянии «Готовность» означает меньше затрат на планирование и больше времени, которое каждый поток получает с течением времени. Чем больше потоков в состоянии «Готовность», тем меньше времени получает каждый поток. Это означает, что со временем будет выполняться меньше вашей работы.
Необходимо найти баланс между количеством ядер, которое у вас есть, и количеством потоков, которое необходимо для достижения максимальной пропускной способности для вашего приложения. Когда дело доходит до управления этим балансом, пулы потоков были отличным ответом. Я покажу вам во второй части, что в Go больше нет этой необходимости. Я думаю, что это одна из приятных вещей, которую в Go сделали для облегчения разработки многопоточных приложений. До написания кода на Go я написал код на C ++ и C # для NT. В этой операционной системе использование пулов потоков IOCP (IO Completion Ports) было критически важным для написания многопоточного программного обеспечения. Как инженер, вам нужно было выяснить, сколько пулов потоков вам нужно, и максимальное количество потоков для любого данного пула, чтобы максимизировать пропускную способность для числа ядер, которые у вас есть. При написании веб-сервисов, взаимодействующих с базой данных, магическое число 3 потока на ядро, всегда обеспечивало наилучшую пропускную способность в NT. Другими словами, 3 потока на ядро минимизировали затраты времени на переключение контекста, максимально увеличив время выполнения на ядрах. При создании пула потоков IOCP я знал, что нужно начинать минимум с 1 потока и максимум с 3 потоками на каждое ядро, идентифицированное на хост-компьютере. Если бы я использовал 2 потока на ядро, это заняло бы больше времени, чтобы выполнить всю работу, потому что у меня был простой, когда я мог выполнять работу. Если я использовал 4 потока на ядро, это также занимало больше времени, потому что у меня было больше задержек при ПК. Баланс 3 потоков на ядро по любой причине всегда казался магическим числом в NT. Что делать, если ваш сервис выполняет много разных видов задач? Это может создать разные и противоречивые задержки. Возможно, он также создает множество различных системных событий, которые необходимо обработать. Может оказаться невозможным найти магическое число, которое работает все время для всех различных рабочих нагрузок. Когда речь идет об использовании пулов потоков для настройки производительности службы, очень сложно найти правильную согласованную конфигурацию.
Как мы уже говорили в первом посте, планировщик ОС является вытесняющим планировщиком. По сути, это означает, что вы не можете предсказать, что планировщик собирается делать в любой момент времени. Ядро принимает решения и все недетерминировано. Приложения, работающие поверх операционной системы, не контролируют то, что происходит внутри ядра с планированием, если они не используют примитивы синхронизации, такие как атомарные инструкции и вызовы мьютекса. Планировщик Go является частью среды исполнения Go, а среда исполнения Go встроена в ваше приложение. Это означает, что планировщик Go работает в пользовательском пространстве над ядром. Текущая реализация планировщика Go является не вытесняющим, а взаимодействующим планировщиком. Быть кооперативным планировщиком означает, что планировщику нужны четко определенные события в пространстве пользователя, которые происходят в безопасных точках кода для принятия решений по планированию. Что хорошо в кооперативном планировщике Go, так это то, что он выглядит и чувствует себя упреждающим. Вы не можете предсказать, что собирается делать планировщик Go. Это связано с тем, что принятие решений для этого планировщика зависит не от разработчиков, а от времени выполнения Go. Важно думать о планировщике Go как об упреждающем планировщике, и, поскольку планировщик недетерминирован, это не слишком сложно.
Точно так же, как потоки, у горутин есть те же три состояния высокого уровня. Они определяют роль, которую планировщик Go играет с любой горутиной. Горутина может находиться в одном из трех состояний: Ожидание, Готовность или Выполнение. Ожидание: это означает, что горутина остановлена и ждет чего-то, чтобы продолжить. Это может происходить по таким причинам, как ожидание операционной системы (системные вызовы) или синхронизация вызовов (атомарные и мьютексные операции). Эти типы задержек являются основной причиной плохой производительности. Готовность: это означает, что горутина хочет получить время, чтобы выполнить назначенные инструкции. Если у вас много горутин, которым нужно время, то горутине придется ждать дольше, чтобы получить время. Кроме того, индивидуальное количество времени, которое получает любая горутина, сокращено, поскольку больше горутин конкурируют за время. Этот тип задержки планирования также может быть причиной плохой производительности. Выполнение: это означает, что горутина была помещена в M и выполняет свои инструкции. Работа, связанная с приложением, завершена. Это то, что все хотят.
Планировщик Go требует четко определенных событий в пространстве пользователя, которые происходят в безопасных точках кода, для переключения контекста. Эти события и безопасные точки проявляются в вызовах функций. Вызовы функций имеют решающее значение для работоспособности планировщика Go. Если вы выполняете какие-либо узкие циклы, которые не выполняют вызовы функций, вы будете вызывать задержки в планировщике и сборке мусора. Крайне важно, чтобы вызовы функций происходили в разумные сроки. Существует четыре класса событий, которые происходят в ваших программах Go, которые позволяют планировщику принимать решения по планированию. Это не значит, что это всегда будет происходить на одном из этих событий. Это означает, что планировщик получает возможность.
Использование ключевого слова go
Cборщик мусора
Системные вызовы
Синхронизация
Использование ключевого слова go Ключевое слово go — это то, как вы создаете горутину. Как только создается новая горутина, она дает планировщику возможность принять решение о планировании. Cборщик мусора (GC) Так как GC работает с собственным набором горутин, эти горутины нуждаются во времени на М для запуска. Это заставляет GC создавать много хаоса в планировании. Тем не менее, планировщик очень умен в том, что делает горутина, и он будет использовать это для принятия решений. Одним из разумных решений является переключение контекста на горутину, которая хочет обратиться к системному ресурсу, и никто больше кроме нее, во время сборки мусора. Когда GC работает, принимается много решений по планированию. Системные вызовы Если горутина делает системный вызов, который заставит ее заблокировать M, планировщик может переключать контекст на другую горутину, на тот же M. Синхронизация Если вызов атомарной операции, мьютекса или канала вызовет блокировку горутины, планировщик может переключить контекст для запуска новой горутины. Как только горутина может работать снова, она может быть поставлена в очередь и в конечном итоге переключиться обратно на M.
Что происходит, когда горутина хочет сделать системный вызов, который не может быть выполнен асинхронно? В этом случае Network poller не может быть использован, и горутина, выполняющая системный вызов, заблокирует M. Это плохо, но не существует способа предотвратить это. Одним из примеров системного вызова, который не может быть выполнен асинхронно, являются системные вызовы на основе файлов. Если вы используете CGO, могут быть другие ситуации, когда вызов C-функций также блокирует M.
Операционная система Windows может выполнять асинхронные системные вызовы на основе файлов. Технически при работе в Windows можно использовать Network poller.
Планировщик Go действительно удивителен тем, как он учитывает тонкости работы ОС и оборудования. Возможность превратить работу ввода-вывода / блокировки в работу с процессором с привязкой к процессору на уровне операционной системы — это то, где мы получаем большой выигрыш в использовании большей мощности процессора с течением времени. Вот почему вам не нужно больше потоков ОС, чем у вас есть виртуальные ядра. Вы можете разумно ожидать, что вся ваша работа будет выполнена (с привязкой к процессору и вводу-выводу / блокировкам) с одним потоком ОС на виртуальное ядро. Это возможно для сетевых приложений и других приложений, которым не нужны системные вызовы, блокирующие потоки ОС. Как разработчик, вы все равно должны понимать, что делает ваше приложение с точки зрения типа работы. Вы не можете создавать неограниченное количество горутин и ожидать потрясающую производительность. Меньше — всегда больше, но с пониманием этой семантики Go-планировщика вы можете принимать лучшие инженерные решения.
Задача планировщика в Go — распределять запущенные горутины между потоками ОС, которые могут исполняться одним или большим количеством процессоров. В многопоточных вычислениях, возникли две парадигмы в планировании: делиться задачами (work sharing) и красть задачи (work stealing).
Work-sharing: Когда процессор генерирует новые потоки, он пытается мигрировать их на другие процессоры, в надежде, что они попадут к простаивающему или недостаточно нагруженному процессору.
Work-stealing: Недостаточно нагруженный процессор активно ищет потоки других процессоров и "крадет" некоторые из них.
Миграция потоков происходит реже при work stealing подходе, чем при work sharing. Когда все процессоры заняты, потоки не мигрируют. Как только появляется простаивающий процессор, рассматривается вариант миграции.
В Go начиная с версии 1.1 планировщик реализован по схеме work stealing и был написан Дмитрием Вьюковым. Эта статья подробно объясняет устройство work stealing планировщиков и как он устроен в Go.
Планировщик Go выполнен по M:N схеме и может использовать несколько процессоров. В любой момент M горутин должны быть распределены между N потоками ОС, которые бегут на максимум GOMAXPROCS процессорах. В Go планировщике используется следующая терминология для горутин, потоков и процессоров:
G: горутина
M: поток ОС (M от Machine)
P: процессор
Далее у нас есть две очереди, специфичные для P. Каждый M должен быть назначен к своему P. P-ы могут не иметь M, если они заблокированы или ожидают окончания системного вызова. В любой момент может быть максимум GOMAXPROCS процессоров — P. В любой момент только один M может исполняться на каждый P. Больше M может создаваться планировщиком, если это требуется.
Каждый цикл планирования заключается в поиске горутины, которая готова к тому, чтобы быть запущенной и её исполнения. При каждом цикле поиск присходит в следующем порядке:
Как только готовая к исполнению G найдена, она исполняется, пока не будет заблокирована.
Заметка: Может показаться, что глобальная очередь имеет преимущество перед локальной, но регулярная проверка глобальной очереди критична для избежания M использования только горутин из локальной очереди.
Когда новая G создается или существующая G становится готовой к исполнению, она помещается в локальную очередь готовых к исполнению горутин текущего P. Когда P заканчивается исполнение G, он пытается вытащить (pop) G из своей очереди. Если список пуст, P выбирает случайным образом другой процессор (P) и пытается украсть половину горутин из его очереди.
В примере выше, P2 не может найти готовых к исполнению горутин. Поэтому он случайно выбирает другой процессор (P1) и крадёт три горутины в свою очередь. P2 теперь сможет их запустить и работа будет более равномерно распределена между процессорами.
Планировщик всегда хочет распределить как можно больше готовых к исполнению горутин на много M, чтобы использовать все процессоры, но, в тоже время, мы должны уметь приостанавливать (park) сильно прожорливые процессы, чтобы сохранять ресурсы CPU и энергию. И при этом, планировщик должен также уметь масштабироваться для задач, которые действительно требуют много вычислительной мощности процессора и большую производительность.
Постоянное вытеснение (preemption) одновременно и дорогое и проблематичное для высоко-производительных программ, где производительность критичней всего. Горутины не должны постоянно прыгать между потоками ОС, поэтому это приводит к повышенной задержки (latency). В добавок ко всему, когда вызываются системные вызовы, поток должен быть постоянно блокироваться и разблокироваться. Это дорого и приводит к большим накладным расходам.
Чтобы уменьшить эти прыжки горутин туда-сюда, планировщик Go реализует так называемые зацикленные потоки (spinning threads). Эти поток используют чуть больше процессорной мощности, но уменьшают вытеснение потоков. Поток считается зациклен, если:
M назначенный на P ищет горутину, которую бы можно было запустить
M не назначенный на P, ищет доступные P.
планировщик также запускает дополнительный поток и зацикливает его, когда готовит новую горутину и есть простаивающий P и нет других зацикленных потоков
В любой момент времени может быть максимум GOMAXPROCS зацикленных M. Когда зацикленный поток находит работу, он выходит из зацикленного состояния.
Простаивающие поток, назначенные на какой-либо P не блокируются, если есть другие M, не назначенные на P. Если создается новая горутина или блокируется M, планировщик проверяет и гарантирует, что есть хотя бы один зацикленный M. Это гарантирует, что все горутины могут быть запущены, если есть возможность и позволяет избежать излишних блокировок/разблокировок M.
Планировщик Go делает много всего для избежания избыточного вытеснения потоков, распределяя их по недоиспользованным процессорам методом "кражи", и также реализацией "зацикленных" потоков, чтобы избежать частых переходов из блокирующего в неблокирующее состояние и обратно.
Планировщик
Ресурсы:
Это означает несколько важных вещей. Во-первых, это означает, что планировщик непредсказуем, когда речь заходит о том, какие потоки будут выбраны для запуска в любой момент времени. Приоритеты потоков вместе с событиями (например, получение данных в сети) делают невозможным определение того, что планировщик выберет и когда. Во-вторых, это означает, что вы никогда не должны писать код, основанный на некотором предполагаемом поведении, которое вам повезло испытать, но не гарантируется, что оно будет происходить каждый раз. Легко позволить себе думать, потому что я видел, как это происходило 1000 раз, это гарантированное поведение. Вы должны контролировать синхронизацию и оркестровку потоков, если вам нужен детерминизм в вашем приложении. Процесс прекращения выполнения процессором одной задачи с сохранением всей необходимой информации и состояния, необходимых для последующего продолжения с прерванного места, и восстановления и загрузки состояния задачи, к выполнению которой переходит процессор называется (ПК). ПК происходит, когда планировщик берет поток выполнения из ядра и заменяет его потоком из состояния готовности. Поток, выбранный из очереди выполнения, переходит в состояние «Выполнение». Поток, который был извлечен, может вернуться в состояние готовности (если он все еще имеет возможность запуска) или в состояние ожидания (если был заменен из-за типа запроса IO-Bound). ПК считается дорогостоящей операцией. Величина задержки, возникающей во время ПК, зависит от различных факторов, но вполне разумно, чтобы она занимала от ~ 1000 до ~ 1500 наносекунд. Учитывая, что аппаратное обеспечение должно быть в состоянии разумно выполнять (в среднем) 12 инструкций в наносекунду на ядро, переключение контекста может стоить от ~ 12k до ~ 18k инструкций задержки. По сути, ваша программа теряет способность выполнять большое количество инструкций во время переключения контекста. Если у вас есть программа, ориентированная на работу, связанную с IO, то переключение контекста будет преимуществом. Как только поток переходит в состояние ожидания, его место занимает другой поток в состоянии готовности. Это позволяет ядру всегда выполнять работу. Это один из самых важных аспектов планирования. Не позволяйте ядру бездействовать, если есть работа (потоки в состоянии «Готовность»). Если ваша программа ориентирована на работу с привязкой к ЦП, переключение контекста станет кошмаром производительности. Поскольку у потока всегда есть работа, переключение контекста останавливает эту работу. Эта ситуация резко контрастирует с тем, что происходит с нагрузкой IO-Bound.
Доступ к данным из основной памяти имеет такую высокую стоимость задержки (от ~ 100 до ~ 300 тактов), что процессоры и ядра имеют локальные кэши для хранения данных близко к аппаратным потокам, которые в них нуждаются. Доступ к данным из кеша обходится намного дешевле (от 3 до 40 тактов) в зависимости от доступа к кешу. Сегодня одним из аспектов производительности является то, насколько эффективно вы можете передавать данные в процессор, чтобы уменьшить эти задержки доступа к данным. При написании многопоточных приложений, которые изменяют состояние, необходимо учитывать механику системы кеширования. Обмен данными между процессором и основной памятью осуществляется с использованием строк кэша. Строка кэша — это 64-байтовый фрагмент памяти, который обменивается между основной памятью и системой кэширования. Каждое ядро получает свою собственную копию любой строки кэша, в которой оно нуждается, что означает, что аппаратное обеспечение использует семантику значений. Вот почему мутации в памяти в многопоточных приложениях могут создавать кошмары производительности. Когда несколько потоков, работающих параллельно, обращаются к одному и тому же значению данных или даже к значениям данных рядом друг с другом, они будут получать доступ к данным в одной строке кэша. Любой поток, работающий на любом ядре, получит свою копию этой же строки кэша. Если один поток в данном ядре вносит изменения в свою копию строки кэша, то с помощью аппаратного обеспечения все остальные копии той же строки кэша должны быть помечены как грязные. Когда Поток пытается получить доступ на чтение или запись к грязной строке кэша, доступ к основной памяти (от ~ 100 до ~ 300 тактовых циклов) необходим для получения новой копии строки кэша. Может быть, на 2-ядерном процессоре это не имеет большого значения, но как быть с 32-ядерным процессором, на котором 32 потока работают параллельно, все получают и изменяют данные в одной строке кэша? А как насчет системы с двумя физическими процессорами по 16 ядер в каждом? Это будет хуже из-за дополнительной задержки для связи между процессорами. Приложение будет перебирать память, а производительность будет ужасной, и, скорее всего, вы не поймете, почему. Это называется проблемой , а также приводит к таким проблемам, как ложное совместное использование. При написании многопоточных приложений, которые будут изменять общее состояние, системы кэширования должны быть приняты во внимание.
Когда ваша программа Go запускается, ей присваивается логический процессор (P) для каждого виртуального ядра, определенного на хост-машине. Если у вас есть процессор с несколькими аппаратными потоками на физическое ядро (Hyper-Threading), каждый аппаратный поток будет представлен вашей программе, как виртуальное ядро. Чтобы лучше понять это, взгляните на системный отчет для моего MacBook Pro. Вы можете видеть, что у меня один процессор с 4 физическими ядрами. В этом отчете не раскрывается количество аппаратных потоков на каждое физическое ядро. Процессор Intel Core i7 имеет технологию Hyper-Threading, что означает, что на физическое ядро приходится 2 аппаратных потока. Это сообщит программе Go, что доступно 8 виртуальных ядер для параллельного выполнения потоков ОС. Чтобы проверить это, рассмотрим следующую программу:
Когда я запускаю эту программу на своем компьютере, результатом вызова функции NumCPU() будет значение 8. Любая программа Go, которую я запускаю на своем компьютере, получит 8(P). Каждому P назначается поток ОС (M). Этот поток по-прежнему управляется ОС, и ОС по-прежнему отвечает за размещение потока в ядре для выполнения. Это означает, что когда я запускаю на своем компьютере программу Go, у меня есть 8 доступных потоков для выполнения моей работы, каждый из которых индивидуально связан с P. Каждой программе Go также дается начальный Goroutine (G). Goroutine — это, по сути, Coroutine, но это Go, поэтому мы заменяем букву C на G и получаем слово Goroutine. Вы можете думать о Goroutines как о потоках уровня приложения, и они во многом похожи на потоки ОС. Так же, как потоки ОС включаются и выключаются ядром, контекстные программы включаются и выключаются контекстом. Последний пазл — это очереди на выполнение. В планировщике Go есть две разные очереди выполнения: глобальная очередь выполнения (GRQ) и локальная очередь выполнения (LRQ). Каждому P присваивается LRQ, который управляет горутинами, назначенными для выполнения в контексте P. Эти горутины по очереди включаются и выключаются из контекста M, назначенного для этого P. GRQ предназначен для горутин, которые не были назначены для P. Существует процесс, чтобы переместить горутины из GRQ в LRQ, который мы обсудим позже. На рисунке представлено изображение всех этих компонентов вместе.
Когда операционная система, на которой вы работаете, имеет возможность обрабатывать системный вызов асинхронно, то, что называется network poller, может использоваться для более эффективной обработки системного вызова. Это достигается с помощью kqueue (MacOS), epoll (Linux) или iocp (Windows) в этих соответствующих ОС. Сетевые системные вызовы могут обрабатываться асинхронно многими операционными системами, которые мы используем сегодня. Именно здесь network poller показывает себя, поскольку его основное назначение — обработка сетевых операций. Используя network poller для сетевых системных вызовов, планировщик может запретить горутинам блокировать M при выполнении этих системных вызовов. Это помогает держать M доступным для выполнения других горутин в LRQ P без необходимости создавать новые M. Это помогает уменьшить нагрузку планирования в ОС. Лучший способ увидеть, как это работает — просмотреть пример. На рисунке показана наша базовая схема планирования. Горутина-1 выполняется на M, и еще 3 Горутины ждут в LRQ, чтобы получить свое время на M. Network poller бездействует, и ему нечего делать. На следующем рисунке Горутина-1 (G1) хочет выполнить сетевой системный вызов, поэтому G1 перемещается в Network poller и обрабатывается как асинхронный сетевой системный вызов. Как только G1 перемещена в Network poller, M теперь доступен для выполнения другой горутины из LRQ. В этом случае Горутина-2 переключается на M. На следующем рисунке системный сетевой вызов завершается асинхронным сетевым вызовом, и G1 перемещается обратно в LRQ для P. После того, как G1 может быть переключен обратно на M, код, связанный с Go, за который он отвечает может выполнить снова. Большой выигрыш в том, что для выполнения сетевых системных вызовов не требуется никаких дополнительных Ms. У Network poller есть поток ОС, и он обрабатывает через event loop.
Давайте посмотрим, что происходит с синхронным системным вызовом (например, файловым вводом / выводом), который приведет к блокировке М. На рисунке показана наша базовая диаграмма планирования, но на этот раз G1 собирается сделать синхронный системный вызов, который заблокирует M1. Далее на рисунке планировщик может определить, что G1 вызвал блокировку М. В этот момент планировщик отсоединяет M1 от P с блокирующим G1, все еще прикрепленным. Затем планировщик вводит новый M2 для обслуживания P. В этот момент G2 может быть выбрана из LRQ и включена в контекст M2. Если M уже существует из-за предыдущего обмена, этот переход происходит быстрее, чем необходимость создания нового M. Следующим шагом завершается системный вызов блокировки, выполненный G1. В этот момент G1 может вернуться в LRQ и снова обслуживаться P. M1 затем уходит в сторону для будущего использования, если этот сценарий должен повториться.
Другим аспектом планировщика является то, что это планировщик «кражи горутин». Это помогает в нескольких областях поддерживать эффективное планирование. Во-первых, последнее, что вам нужно, это M перейти в состояние ожидания, потому что, как только это произойдет, ОС переключит M из ядра с помощью контекста. Это означает, что P не может выполнить какую-либо работу, даже если есть Goroutine в работоспособном состоянии, пока M не переключится обратно на ядро. «Кража горутин» также помогает сбалансировать временные интервалы между всеми P, чтобы работа распределялась лучше и выполнялась более эффективно. На рисунке у нас есть многопоточная программа Go с двумя P, обслуживающими четыре G каждый и один G в GRQ. Что произойдет, если один из P быстро обслуживает все свои G? Далее у P1 больше нет горутин для выполнения. Но есть горутины в работоспособном состоянии, как в LRQ для P2, так и в GRQ. Это момент, когда P1 нужно украсть горутину. Правила кражи горутины следующие. Весь код можно посмотреть в исходниках runtime.
Таким образом, основываясь на этих правилах, P1 должен проверить P2 на наличие горутин в своем LRQ и взять половину того, что он найдет. Что произойдет, если P2 завершит обслуживание всех своих программ и у P1 ничего не останется в LRQ? P2 завершил всю свою работу и теперь должен украсть горутины. Во-первых, он будет смотреть на LRQ P1, но не найдет никаких Goroutines. Далее он будет смотреть на GRQ. Там он найдет G9. P2 крадет G9 из GRQ и начинает выполнять работу. Что хорошо во всей этой краже, так это то, что она позволяет M оставаться занятой и не бездействовать.
Имея механику и семантику, я хочу показать вам, как все это объединяется, чтобы планировщик Go мог выполнять больше работы с течением времени. Представьте себе многопоточное приложение, написанное на C, в котором программа управляет двумя потоками ОС, которые передают сообщения друг другу. На рисунке есть 2 потока, которые передают сообщение туда и обратно. Поток 1 получает context-switched core 1 и теперь выполняется, что позволяет потоку 1 отправить свое сообщение потоку 2. Далее, когда поток 1 заканчивает отправку сообщения, теперь ему нужно дождаться ответа. Это приведет к тому, что поток 1 будет отключен от контекста ядра 1 и переведен в состояние ожидания. Как только поток 2 получает уведомление о сообщении, он переходит в работоспособное состояние. Теперь ОС может выполнять переключение контекста и запускать поток 2 на ядре, которое оказывается ядром 2. Затем поток 2 обрабатывает сообщение и отправляет новое сообщение обратно в поток 1. Далее поток снова переключается в контекст, когда сообщение от потока 2 принимается потоком 1. Теперь поток 2 переключается из состояния выполнения в состояние ожидания, а поток 1 переключается из состояния ожидания в состояние готовности и, наконец, возвращается в состояние выполнения, что позволяет ему обрабатывать и отправлять новое сообщение обратно. Все эти переключения контекста и изменения состояния требуют времени для выполнения, что ограничивает скорость выполнения работы. Поскольку каждое переключение контекста влечет за собой задержку ~ 1000 наносекунд, и мы надеемся, что аппаратное обеспечение выполняет 12 инструкций в наносекунду, вы смотрите на 12 000 инструкций, более или менее не выполняющихся во время этих переключений контекста. Так как эти потоки также пересекаются между различными ядрами, вероятность возникновения дополнительной задержки cache-line misses также высока. На рисунке есть две горутины, которые находятся в гармонии друг с другом, передавая сообщение туда-сюда. G1 получает переключение контекста M1, который работает на Core 1, что позволяет G1 выполнять свою работу. Далее, когда G1 заканчивает отправку сообщения, теперь ему нужно дождаться ответа. Это приведет к тому, что G1 будет отключен от контекста M1 и переведен в состояние ожидания. Как только G2 уведомляется о сообщении, оно переходит в работоспособное состояние. Теперь планировщик Go может выполнять переключение контекста и запускать G2 на M1, который все еще работает на Core 1. Затем G2 обрабатывает сообщение и отправляет новое сообщение обратно в G1. Cледующим шагом все снова переключается, когда сообщение, отправленное G2, принимается G1. Теперь контекст G2 переключается из состояния выполнения в состояние ожидания, а контекст G1 переключается из состояния ожидания в состояние выполнения и, наконец, обратно в состояние выполнения, что позволяет ему обрабатывать и отправлять новое сообщение обратно. Вещи на поверхности, кажется, не отличаются. Все те же изменения контекста и изменения состояния происходят независимо от того, используете ли вы Потоки или Горутины. Однако существует большая разница между использованием Потоков и Горутин, которая может быть неочевидна на первый взгляд. В случае использования горутин одни и те же потоки ОС и ядро используются для всей обработки. Это означает, что с точки зрения ОС Поток ОС никогда не переходит в состояние ожидания; ни разу. В результате все те инструкции, которые мы потеряли при переключении контекста, при использовании потоков, не теряются при использовании горутин. По сути, Go превратил работу IO / Blocking в работу с привязкой к процессору на уровне ОС. Поскольку все переключение контекста происходит на уровне приложения, мы не теряем те же самые ~ 12 тыс. инструкций (в среднем) на переключение контекста, которые мы теряли при использовании потоков. В Go те же переключатели контекста стоят вам ~ 200 наносекунд или ~ 2,4 тыс. команд. Планировщик также помогает повысить эффективность строк кэширования и . Вот почему нам не нужно больше потоков, чем у нас есть виртуальные ядра. В Go можно со временем выполнять больше работы, потому что планировщик Go пытается использовать меньше потоков и делать больше на каждом потоке, что помогает снизить нагрузку на ОС и оборудование.
События планирования можно отслеживать с помощью -а. Вы можете детально докопаться до всего, что происходит внутри планировщика, особенно если считаете, что в вашем случае происходит не эффективное использование процессоров.