Go планировщик использует гибридную модель кооперативного и преемптивного многозадачности, чтобы предотвратить голодание без вмешательства операционной системы. Начиная с версии 1.14, в среде выполнения добавляются асинхронные точки преемпции, отправляя сигналы SIGURG потокам, выполняющим горутины, которые превышают свой временной отрезок (обычно 10 мс). Когда обработчик сигнала обнаруживает безопасную точку — например, когда горутина собирается вызвать функцию или обратиться к стеку — планировщик сохраняет контекст и переключается на другую выполняемую горутину. Этот механизм гарантирует, что даже плотные циклы, загружающие процессор, без вызовов функций не могут монополизировать Процессор (P) бесконечно.
Наша платформа высокочастотной торговли столкнулась с катастрофическими всплесками задержки во время рыночной волатильности, когда одна аналитическая горутина, выполняющая сложные симуляции Монте-Карло, замораживала конвейеры обработки заказов на сотни миллисекунд. Проблема возникла из-за того, что горутина выполняла плотный математический цикл без вызовов функций, предотвращая планировщик от преемпции до Go 1.14.
Мы оценили три различные подходы для решения этой проблемы. Первый вариант заключался в ручной вставке вызовов runtime.Gosched() в циклы симуляции. Этот подход обеспечивал немедленное смягчение проблемы, но создавал значительную нагрузку для обслуживания и требовал от разработчиков глубоких знаний о планировщике, создавая хрупкий код, который мог бы регрессировать при рефакторинге.
Второе решение предложило изолировать аналитическую нагрузку в отдельный микросервис с ограничениями по ЦП. Хотя это обеспечивало жесткую изоляцию и независимое масштабирование, накладные расходы на сетевая сериализация и дополнительная задержка межпроцессного взаимодействия нарушали наши требования к задержке менее миллисекунды для расчетов рисков.
Мы в конечном итоге выбрали обновление среды выполнения до Go 1.20 и явную настройку GOMAXPROCS в соответствии с физическими ядрами ЦП. Это обновление обеспечивало асинхронную преемпцию через сигналы, позволяя планировщику принудительно освобождать горутину с загрузкой процессора каждые 10 мс без модификации кода. Метрики после развертывания показали, что P99 задержка стабилизировалась на уровне 8 мс в период пиковой нагрузки, устранив каскады тайм-аутов и сохранив простоту архитектуры с единым процессом.
Почему плотный цикл без вызовов функций вызывает проблемы с планированием в старых версиях Go, но не в новых?
До Go 1.14 планировщик полагался исключительно на кооперативную преемпцию, что означало, что горутины добровольно освобождали процессор только при вызовах функций, операциях с каналами или конфликте мьютекса. Плотный цикл, выполняющий чисто арифметические операции, никогда не попадал в безопасную точку, фактически монополизируя свой Процессор (P) до завершения. Современный Go использует асинхронную преемпцию, отправляя сигналы SIGURG потоку, который инициирует переключение контекста в следующий безопасный момент, независимо от того, происходит ли вызов функции.
Как планировщик Go решает, какая горутина будет выполнена следующей, когда Процессор (P) становится доступным?
Планировщик реализует алгоритм кражи работы, который сначала проверяет локальную очередь выполнения текущего P, затем пытается украсть половину горутин из локальной очереди другого P, используя рандомизированный начальный индекс для уменьшения конкуренции. Если локальные очереди пусты, он проверяет глобальную очередь выполнения каждые 61 тик планировщика, чтобы предотвратить голодание новосозданных горутин. Эта иерархическая выборка минимизирует затраты на синхронизацию, обеспечивая балансировку нагрузки по всем доступным потокам Машины (M).
Что происходит с Процессором (P), когда горутина выполняет блокирующий системный вызов, такой как ввод-вывод файла?
Когда горутина блокируется на системном вызове, среда выполнения Go немедленно отключает поток Машины (M) от его P и назначает этот P новому или неактивному M, позволяя другим горутинам продолжать выполнение на той же абстракции ОС. Исходный M заходит в системный вызов и ждет, пока ядро завершит операцию; по возвращении он пытается заново захватить свой оригинальный P или паркуется, если P в данный момент привязан к другому потоку. Эта мультиплексирование M:N предотвращает простои потоков ОС во время ввода-вывода, поддерживая высокую загрузку ЦП по всем тысячам горутин.