Планировщик Go
- суббота, 29 марта 2025 г. в 00:00:08
Язык программирования Go был разработан для высокопроизводительных многопоточных приложений, и его система планирования горутин играет ключевую роль в эффективном использовании аппаратных ресурсов. В отличие от традиционных потоков ОС, горутины легче, создаются быстрее и управляются встроенным планировщиком Go, который распределяет задачи между доступными процессорами.
В этом тексте я рассмотрю, как Go-программа использует вычислительные мощности, как работает планировщик горутин и какие механизмы обеспечивают эффективное выполнение кода. Мы разберем принципы распределения задач, взаимодействие горутин с потоками ОС, а также механизмы синхронизации и асинхронного ввода-вывода. Это поможет лучше понять, как Go обеспечивает высокую производительность при работе с конкурентными процессами.
Когда Go-приложение запускается, оно получает логические процессоры в количестве, равном числу виртуальных ядер, доступных на машине. Если процессор поддерживает многопоточность (Hyper-Threading), каждое аппаратное ядро будет представляться в Go как виртуальное ядро.
Чтобы лучше понять это, я посмотрел системные характеристики своего MacBook Air.
Можно увидеть, что у меня MacBook Air с одним процессором и восемью физическими ядрами. Процессор Apple M1/M2 использует архитектуру с энергоэффективными и производительными ядрами, но для Go-программы он представляется как 8 виртуальных ядер, доступных для параллельного выполнения потоков ОС.
Чтобы проверить это, можно запустить следующий код:
package main
import (
"fmt"
"runtime"
)
func main() {
// NumCPU возвращает количество логических
//процессоров, используемых текущим процессом.
fmt.Println(runtime.NumCPU())
}
Когда я запускаю этот код на своей системе, функция NumCPU() возвращает значение 8. Это означает, что любая программа на Go, запущенная на моём компьютере, будет использовать 8 логических процессоров . Каждый логический процессор управляет потоком операционной системы, который контролируется самой ОС. Операционная система отвечает за размещение этих потоков на физических ядрах процессора.
Каждая программа на Go также создаёт первую горутину — это поток выполнения программы. Горутина по сути является корутиной, но в Go используется именно термин "горутина". Горутины можно сравнить с потоками операционной системы, но они легковеснее и управляются планировщиком Go. Подобно тому, как операционная система переключает потоки между физическими ядрами, планировщик Go переключает горутины между потоками.
Последним элементом этой системы являются очереди выполнения (run queues) . В планировщике Go существует два типа очередей:
Глобальная очередь выполнения (Global Run Queue, GRQ) .
Локальная очередь выполнения (Local Run Queue, LRQ) .
Каждый логический процессор имеет свою локальную очередь выполнения (LRQ). Эта очередь управляет горутинами, назначенными для выполнения в контексте данного логического процессора. Эти горутины по очереди переключаются на поток, связанный с этим логическим процессором.
Глобальная очередь выполнения (GRQ) используется для горутин, которые ещё не были назначены ни одному логическому процессору. Существует механизм перемещения горутин из глобальной очереди (GRQ) в локальные очереди (LRQ), который мы рассмотрим позже.
На Рис. 1 представлено изображение всех этих компонентов вместе.
Планировщик операционной системы является вытесняющим, поэтому в любой момент времени невозможно предсказать, какое решение он примет. Ядро управляет потоками (threads), и всё происходит недетерминированно. Приложения, работающие поверх ОС, не могут напрямую контролировать процесс планирования внутри ядра, если только они не используют специальные механизмы, такие как приоритеты потоков, реальное время (real-time scheduling) или примитивы синхронизации (например, атомарные инструкции, мьютексы и семафоры).
Планировщик Go является частью Go runtime, который встроен в само приложение. Это означает, что он работает в пользовательском пространстве (user space), над ядром операционной системы. Однако важно понимать, что поведение планировщика Go изменилось с момента его первоначальной реализации.
В текущей реализации планировщик Go поддерживает вытеснение горутин, хотя он исторически начинался как кооперативный. Это означает, что:
Для принятия решений о планировании планировщику больше не нужны чётко определённые события в безопасных точках кода.
Даже если горутина выполняет долгие вычисления без явных точек передачи управления, планировщик может принудительно прервать её выполнение через сигналы операционной системы (например, SIGURG).
Горутины по-прежнему могут передавать управление явно, например, через системные вызовы, блокировки или каналы.
Раньше (до версии Go 1.2) планировщик действительно был полностью кооперативным. Это означало, что горутины могли выполняться до тех пор, пока они явно не передавали управление обратно в планировщик. Если горутина выполняла длительные вычисления без таких точек передачи управления, она могла монополизировать процессорное время.
Однако начиная с версии Go 1.2 , планировщик был значительно улучшен, и в него был добавлен механизм превентивного вытеснения (preemptive scheduling). Этот механизм позволяет планировщику принудительно прерывать выполнение горутин, даже если они выполняют долгие вычисления без явных точек передачи управления. Современные версии Go (начиная с 1.14) поддерживают полноценное вытеснение горутин.
Гениальность современного планировщика Go заключается в том, что он сочетает кооперативный и вытесняющий подходы. Хотя разработчики больше не управляют планированием явно, решения о переключении между горутинами принимаются автоматически Go runtime. Это делает поведение планировщика недетерминированным для разработчика, что создаёт иллюзию вытесняющего планирования.
Как и потоки, горутины имеют три основных состояния, которые определяют роль планировщика Go для каждой горутины. Горутина может находиться в одном из трёх состояний: waiting, runnable или executing.
Waiting: Горутина остановлена и ждёт какого-то события, чтобы продолжить выполнение. Это может быть связано с ожиданием работы с операционной системой (системные вызовы) или синхронизацией (атомарные операции и мьютексы). Такие задержки являются одной из основных причин плохой производительности.
Runnable: Горутина ожидает выделения OS-потока (M) для выполнения своих инструкций. Если в системе много горутин, им приходится дольше ждать своей очереди. С увеличением количества горутин время выполнения каждой из них сокращается, что также может негативно сказаться на производительности.
Executing: Горутина была назначена на OS-поток (M) и выполняет свои инструкции. В этот момент выполняется полезная работа приложения.
Планировщик Go требует чётко определённых событий в пользовательском пространстве, которые происходят в безопасных точках кода для выполнения переключений контекста. Эти события и безопасные точки проявляются в вызовах функций. Вызовы функций критически важны для работы планировщика Go. В Go 1.12 было принято изменение, позволяющее планировщику использовать некооперативные техники прерывания, что дало возможность прерывать tight loops.
В Go-программе существует четыре класса событий, которые позволяют планировщику принимать решения о планировании горутин:
Использование ключевого слова go
Сборка мусора
Системные вызовы
Синхронизация и оркестрация
Использование ключевого слова go
Ключевое словоgo
используется для создания горутин. Когда создаётся новая горутина, это даёт планировщику возможность принять решение о планировании.
Сборка мусора
Поскольку сборка мусора (Garbage Collection, GC) выполняется с использованием собственных горутин , этим горутинам требуется время на потоках операционной системы для выполнения своей работы. Это вносит определённый хаос в процесс планирования. Однако планировщик Go очень "умный" — он понимает, что именно делает каждая горутина, и использует это знание для принятия обоснованных решений.
Одним из таких решений является переключение контекста между горутинами. Планировщик старается переключать горутины, которые активно работают с кучей (heap) , на те, которые не взаимодействуют с кучей, особенно во время выполнения сборки мусора. Это помогает минимизировать задержки и улучшить общую производительность программы. Когда запускается сборка мусора, планировщик принимает множество решений о том, как и когда переключать горутины между потоками. Эти решения направлены на то, чтобы сборка мусора выполнялась максимально эффективно, не блокируя выполнение других задач программы.
Системные вызовы
Если горутина выполняет системный вызов, который блокирует поток операционной системы, то планировщик Go может выполнить переключение контекста. Это означает, что текущая горутина временно снимается с потока, а на этот же поток назначается другая горутина для продолжения работы. Однако иногда возникают ситуации, когда для выполнения других горутин, находящихся в очереди логического процессора, требуется новый поток операционной системы. Как именно это происходит и как планировщик решает создавать или использовать дополнительные потоки, будет объяснено более подробно в следующем разделе.
Синхронизация и оркестрация
Если операция, связанная с атомарными действиями, мьютексами или каналами, вызывает блокировку горутины , то планировщик Go может выполнить переключение контекста. Это означает, что текущая горутина временно приостанавливается, а вместо неё на поток операционной системы назначается другая горутина для продолжения работы.
Когда заблокированная горутина снова готова к выполнению, она возвращается в очередь на выполнение. В дальнейшем планировщик может снова выбрать её из очереди и назначить на поток операционной системы для возобновления работы.
Когда операционная система может обрабатывать системные вызовы асинхронно, в Go используется специальный механизм, называемый сетевой поллер (network poller) , для более эффективной обработки таких вызовов. Этот механизм опирается на низкоуровневые технологии, такие как:
kqueue (на macOS),
epoll (на Linux),
iocp (на Windows).
Эти технологии зависят от операционной системы, но их общая цель — обеспечить асинхронную обработку системных вызовов.
Сетевые системные вызовы могут выполняться асинхронно во многих современных операционных системах. Именно поэтому этот механизм называется сетевой поллер (network poller) — его основная задача заключается в обработке сетевых операций. Используя сетевой поллер для таких вызовов, планировщик Go может предотвратить блокировку потока операционной системы во время выполнения этих вызовов.
Благодаря этому подходу поток остаётся доступным для выполнения других горутин из локальной очереди выполнения (LRQ ) логического процессора. Это также уменьшает необходимость создания новых потоков, что снижает нагрузку на планировщик операционной системы и повышает общую эффективность работы программы.
Лучший способ увидеть, как это работает, — это рассмотреть пример.
На Рис. 3 показана основная схема распределения задач в планировщике Go. Горутина G1 выполняется на потоке операционной системы (M, Machine ), а ещё три горутины (G2, G3, G4 ) ожидают своей очереди в локальной очереди выполнения (LRQ, Local Run Queue) , чтобы получить доступ к логическому процессору (P, Processor) .
В данный момент сетевой поллер (network poller) неактивен и не выполняет никаких операций.
На Рис. 4 показано, как Горутина G1 выполняет сетевой системный вызов. Для этого она перемещается в сетевой поллер (network poller) , где запрос обрабатывается асинхронно. Как только Горутина G1 покидает поток операционной системы, этот поток освобождается и может быть использован для выполнения другой горутины из локальной очереди выполнения (LRQ) .
В данном случае управление передаётся Горутине G2 , которая запускается на потоке с помощью механизма переключения контекста (context switching) .
На Рис. 5 показано, что асинхронный сетевой системный вызов завершён сетевым поллером (Network Poller) . После этого Горутина G1 возвращается в локальную очередь выполнения (LRQ) логического процессора.
Как только появляется возможность, Горутина G1 снова запускается на потоке операционной системы с помощью механизма переключения контекста (context switching) , и её код продолжает выполняться. Главное преимущество такого подхода заключается в том, что сетевые операции не требуют создания дополнительных потоков операционной системы. Сетевой поллер (Network Poller) эффективно управляет сетевыми событиями, используя существующие потоки операционной системы, что снижает нагрузку на систему и повышает производительность.
Что происходит, если горутина должна выполнить системный вызов, который нельзя обработать асинхронно ? В таких случаях сетевой поллер (Network Poller) не может помочь, и горутина, выполняющая этот вызов, блокирует поток операционной системы . Это нежелательная ситуация, так как она снижает эффективность планировщика Go, но иногда её невозможно избежать.
Примером такого системного вызова является файловый ввод-вывод (I/O) . Если в программе используется cgo , то некоторые вызовы C-функций также могут блокировать поток. Однако стоит отметить, что на платформе Windows файловый ввод-вывод может обрабатываться асинхронно. В таких случаях сетевой поллер может быть задействован для управления этими операциями.
Теперь разберёмся подробнее, что происходит, если синхронный системный вызов (например, файловый ввод-вывод) блокирует поток .
На Рис. 6 представлена базовая схема планирования, но теперь горутина 1 выполняет синхронный системный вызов, который блокирует поток M1.
На Рис. 7 показано, что если горутина G1 выполняет операцию, блокирующую поток M1 , то планировщик Go отсоединяет этот поток от логического процессора. Это необходимо, чтобы другие горутины могли продолжать выполняться.
Для этого создаётся новый поток операционной системы (M2 ), который подключается к тому же логическому процессору и продолжает выполнение задач. Теперь горутина G2 выбирается из очереди задач (локальной очереди выполнения, LRQ) и начинает выполняться на новом потоке (M2 ). Если в системе уже есть свободный поток, планировщик может использовать его вместо создания нового. Это позволяет ускорить переключение, так как создание нового потока требует дополнительных ресурсов.
На Рис. 8 показано, что после завершения блокирующего системного вызова горутина G1 возвращается в локальную очередь задач (LRQ) . Теперь она может снова выполняться на логическом процессоре (P, Processor) , когда наступит её очередь согласно планировщику.
Если подобные ситуации повторяются (например, горутина часто выполняет блокирующие вызовы), поток операционной системы (M1) может быть временно отложен планировщиком. Это делается для того, чтобы избежать создания лишней нагрузки на систему и оптимизировать использование ресурсов.
Кража работы (Work Stealing)
Ещё одна важная особенность планировщика Go — это механизм кражи работы (work stealing) . Этот механизм помогает повысить эффективность выполнения программы.
Во-первых, нежелательно, чтобы поток операционной системы переходил в режим ожидания, так как в этом случае операционная система может убрать его с физического ядра процессора. Это приведёт к тому, что логический процессор останется без работы, даже если в системе есть готовые к выполнению горутины.
Во-вторых, механизм кражи работы помогает равномерно распределять горутины между логическими процессорами. Благодаря этому снижаются задержки и повышается общая производительность программы, так как нагрузка распределяется более сбалансированно.
Давайте рассмотрим пример.
На рис. 9 изображена многопоточная программа на Go, где два логических процессора обрабатывают по 4 горутины каждый. Кроме того, ещё 1 горутина находится в глобальной очереди выполнения (GRQ) .
Что произойдёт, если один из логических процессоров (P ) завершит выполнение всех своих горутин раньше?
На рис. 10 показана ситуация, когда у логического процессора P1 больше нет горутин для выполнения. Однако в локальной очереди (LRQ) у логического процессора P2 , а также в глобальной очереди (GRQ) , есть готовые к выполнению горутины. В этот момент P1 , чтобы не простаивать, должен "украсть" работу у другого логического процессора (P2 ) или забрать задачу из глобальной очереди (GRQ) .
runtime.schedule() {
// Только 1/61 времени проверяй глобальную очередь готовых Goroutines (GRQ).
// Если не найдено, проверь локальную очередь (LRQ).
// Если не найдено,
// попробуй украсть работу у других P.
// Если не получилось, снова проверь глобальную очередь.
// Если не найдено, поллинг сети.
}
Согласно правилам, описанным выше, логический процессор P1 сначала должен проверить локальную очередь выполнения (LRQ, Local Run Queue) у логического процессора P2 .Если в локальной очереди (LRQ) у P2 есть горутины, то P1 заберёт из неё половину горутин.
На рис. 11 показано, как логический процессор P1 забирает половину горутин из локальной очереди (LRQ) у логического процессора P2 и начинает их выполнять. Но что произойдёт, если P2 завершит выполнение всех своих горутин, а в локальной очереди (LRQ) у P1 больше не останется задач?
На рис. 12 показано, что логический процессор P2 завершил выполнение всех своих горутин и теперь ищет новые задачи для работы. Сначала P2 проверяет локальную очередь (LRQ) у логического процессора P1 , но в ней уже нет доступных горутин. После этого P2 обращается к глобальной очереди выполнения (GRQ) , где находит горутину G9 и забирает её для выполнения.
На рис. 13 показано, как логический процессор P2 забирает горутину G9 из глобальной очереди выполнения (GRQ) и начинает её выполнять.
Преимущество использования механизма кражи работы (work-stealing) заключается в том, что это позволяет потокам операционной системы (M, Machine ) оставаться занятыми и не переходить в неактивное состояние. Когда потоки простаивают, операционная система может переместить их с физических ядер процессора, что замедляет последующее возобновление работы. Благодаря краже работы , потоки остаются активными и готовыми к выполнению задач.
Этот процесс внутри планировщика Go иногда называют "вращением" потоков (spinning). "Вращение" имеет дополнительные преимущества, такие как снижение накладных расходов на переключение контекста и более эффективное использование ресурсов.
С учетом механики и семантики рассмотрим, как всё это работает вместе, чтобы позволить планировщику Go выполнять больше работы со временем. Представим многопоточное приложение, написанное на C, где программа управляет двумя потоками ОС, которые обмениваются сообщениями.
T - Tread(поток)
На рис. 14 показано, как два потока обмениваются сообщениями. Поток 1 переключается на ядро 1 и начинает выполнение, что позволяет ему отправить сообщение потоку 2. Как именно передается сообщение, не имеет значения. Важно лишь состояние потоков на протяжении этой оркестрации.
На рис. 15 показано, что как только Поток 1 завершает отправку сообщения, он должен ожидать ответа. Это приведет к тому, что Поток 1 будет переключен с Ядра 1 и переведен в состояние ожидания. Как только Поток 2 получит уведомление о сообщении, он переходит в состояние готовности. Операционная система может выполнить переключение контекста и запустить Поток 2 на Ядре 2. Далее Поток 2 обрабатывает сообщение и отправляет новое сообщение обратно Потоку 1.
"На рис. 16 показано, как Потоки снова переключаются при контексте, когда Поток 1 получает сообщение от Потока 2. Теперь Поток 2 переключается с состояния выполнения в состояние ожидания, а Поток 1 — с состояния ожидания в состояние готовности и, наконец, обратно в состояние выполнения, что позволяет ему обработать и отправить новое сообщение обратно.
Все эти переключения контекста и изменения состояний требуют времени, что ограничивает скорость выполнения работы. Каждое переключение контекста может приводить к задержке около 1000 наносекунд, и если оборудование выполняет 12 инструкций на наносекунду, это означает, что в среднем не выполняется около 12 000 инструкций в процессе переключений контекста. Поскольку эти Потоки также переключаются между различными Ядрами, вероятность возникновения дополнительной задержки из-за промахов в кэш-линии также велика.
Теперь давайте рассмотрим тот же пример, но с использованием Горутин и планировщика Go.
На рис. 16 показано, как две горутины (G1 и G2) взаимодействуют между собой, передавая сообщение туда и обратно. Горутина G1 переключается на поток операционной системы M1 , который выполняется на Ядре 1 процессора. Это позволяет G1 выполнить свою задачу. Её работа заключается в том, чтобы отправить сообщение горутине G2 .
На рис. 18 показано, что как только горутина G1 завершает отправку сообщения, ей необходимо дождаться ответа. Это приводит к тому, что G1 освобождает поток операционной системы M1 и переходит в состояние ожидания .
Как только горутина G2 получает уведомление о входящем сообщении, она переходит в состояние готовности . Теперь планировщик Go может выполнить переключение контекста и запустить G2 на том же потоке M1 , который продолжает работать на Ядре 1 процессора. После этого G2 начинает обрабатывать сообщение и отправляет новый ответ обратно горутине G1 .
На рис. 18 показано очередное переключение контекста, которое происходит, когда сообщение, отправленное горутиной G2 , принимается горутиной G1 . В этот момент G2 переходит из состояния выполнения в состояние ожидания, а G1 — из состояния ожидания в состояние готовности, а затем обратно в состояние выполнения. Это позволяет G1 обработать сообщение и отправить новый ответ обратно G2 .
На первый взгляд может показаться, что процесс не сильно отличается от работы с потоками операционной системы. Те же переключения контекста и изменения состояний происходят как при использовании потоков, так и при использовании горутин. Однако между этими подходами существует принципиальная разница , которая становится очевидной при более глубоком рассмотрении.
Когда используются горутины , один и тот же поток операционной системы и одно и то же ядро процессора остаются задействованными для всей обработки. Это означает, что с точки зрения операционной системы поток никогда не переходит в состояние ожидания.
В результате все те накладные расходы, которые возникают при переключении контекста при использовании потоков (например, 12 тысяч инструкций на каждое переключение), не теряются при использовании горутин . В Go переключение контекста происходит на уровне приложения и стоит значительно дешевле: всего около 200 наносекунд или ~2,4 тысячи инструкций .
Планировщик Go также помогает повысить эффективность работы с кэшами процессора и системой NUMA (Non-Uniform Memory Access). Благодаря этому:
Мы можем выполнять больше работы за то же время.
Нет необходимости создавать больше потоков, чем количество виртуальных ядер в системе.
Планировщик пытается использовать меньше потоков и выполнять больше работы на каждом потоке , что снижает нагрузку на операционную систему и оборудование.
По сути, Go превращает работу с операциями ввода-вывода (I/O ) и блокирующими вызовами в высокоэффективную обработку на уровне процессора. Все переключения контекста происходят внутри приложения, что делает работу горутин значительно более производительной по сравнению с традиционными потоками.
Планировщик Go впечатляет своим продуманным дизайном, который учитывает особенности работы операционной системы и аппаратного обеспечения. Использование модели M:N позволяет эффективно управлять горутинами, перераспределяя их между потоками ОС в зависимости от нагрузки. Это дает значительное преимущество при работе с IO и блокирующими операциями, превращая их в управляемые вычислительные задачи на уровне планировщика.
Благодаря этой архитектуре в большинстве случаев нет необходимости создавать больше потоков ОС, чем виртуальных ядер. Это позволяет эффективно выполнять как процессоро-зависимые, так и IO-ориентированные задачи, минимизируя накладные расходы. Однако при наличии системных вызовов, которые блокируют потоки ОС, Go может динамически создавать дополнительные потоки, чтобы избежать блокировки выполнения других горутин.