AI [Перевод] Планировщик Go

AI

Редактор
Регистрация
23 Август 2023
Сообщения
3 709
Лучшие ответы
0
Реакции
0
Баллы
243
Offline
#1

Эта статья посвящена языку программирования Go 1.24, работающему на Linux на архитектуре ARM. Она может не охватывать специфические для других операционных систем (ОС) или аппаратных архитектур детали.
❯ Введение


Предполагается, что вы обладаете базовым пониманием параллельного программирования в Go (горутины (goroutines), каналы (channels) и т.д.).

Go или Golang, представленный в 2009 году, неуклонно растет в популярности как язык программирования для разработки многопоточных (concurrent) приложений. Он спроектирован, чтобы быть простым, эффективным и легким в использовании.

Модель параллелизма Go основана на концепции горутин — простых пользовательских потоках (threads), управляемых средой выполнения (runtime) Go в пользовательском пространстве. Go предоставляет полезные примитивы для синхронизации, такие как каналы, чтобы помочь разработчикам эффективно писать код для параллельных вычислений.

Понимание того, как работает планировщик задач (scheduler) Go, является критически важным для разработчиков, которые хотят писать эффективные многопоточные программы. Это также помогает в устранении проблем с производительностью программ и ее оптимизации. В этой статье рассматривается, как планировщик Go развивался со временем и как работает код Go, который мы пишем.

❯ Компиляция и среда выполнения Go


Компиляция программы на Go происходит в три этапа:


  1. Компиляция (compilation): исходные файлы (*.go) компилируются в файлы ассемблера (*.s).


  2. Сборка (assembling): файлы ассемблера собираются в объектные файлы (*.o).


  3. Связывание (linking): объектные файлы (*.o) связываются вместе для создания единого исполняемого двоичного файла.


Понимание планировщика Go невозможно без понимания среды выполнения Go. Среда выполнения — это ядро языка программирования, предоставляющее основные функции, такие как планирование, управление памятью и структуры данных. Проще говоря, это набор функций и структур данных, обеспечивающих работу программы. Реализация среды выполнения находится в пакете runtime. Она написана на Go и ассемблерном коде, причем, последний используется в основном для выполнения низкоуровневых операций, таких как работа с регистрами (registers).



При компиляции некоторые ключевые слова и встроенные функции заменяются компилятором на вызовы функций среды выполнения. Например, ключевое слово go, используемое для запуска новой горутины, заменяется вызовом функции runtime.newproc, а функция new, используемая для выделения памяти для нового объекта, заменяется вызовом функции runtime.newobject.

Возможно, вас удивит тот факт, что некоторые функции среды выполнения не имеют реализации на Go. Например, функция getg заменяется низкоуровневым ассемблерным кодом во время компиляции. Другие функции, такие как gogo, зависят от платформы и полностью реализованы на ассемблере. Задача компоновщика (linker) Go - связать эти ассемблерные реализации с их объявлениями на Go.

В некоторых случаях функция не имеет реализации в своем пакете, но связана с определением в среде выполнения с помощью директивы компилятора //go:linkname. Например, часто используемая функция time.Sleep связана таким образом со своей фактической реализацией в runtime.timeSleep.

❯ Примитивный планировщик


Планировщик — это не отдельный объект, а набор функций, облегчающих планирование. Он работает не в отдельном потоке, а в тех же потоках, что и горутины.

Возможно, вам уже знакомы модели многопоточности. Они определяют, как потоки пользовательского пространства (корутины в Kotlin, Lua или горутины в Go) мультиплексируются (распределяются) на один или несколько потоков ядра. Существует три основных модели: многие к одному (N:1), один к одному (1:1) и многие ко многим (M:N).



Многие к одному



Один к одному



Многие ко многим

В Go используется модель M:N, позволяющая мультиплексировать несколько горутин на несколько потоков ядра. Такой подход, хоть и является более сложным, позволяет использовать преимущества многоядерных систем и повышает эффективность программ при выполнении системных вызовов, решая проблемы других моделей. Поскольку ядро не знает, что такое горутина, и предоставляет поток только в качестве единицы параллельного выполнения приложений пользовательского пространства, именно поток ядра выполняет логику планирования и код горутин, а также осуществляет системные вызовы от их имени.

На ранних этапах, особенно до версии 1.1, Go реализовывал модель многопоточности M:N наивным способом. Существовали только две сущности: горутины (G) и потоки ядра (M, машины). Для хранения всех запущенных горутин использовалась одна глобальная очередь выполнения (global run queue), защищенная блокировкой (lock) для предотвращения возникновения состояния гонки (race condition). Планировщик, работающий в каждом потоке, отвечал за извлечение горутины из глобальной очереди и ее выполнение.



Сегодня Go известен своей высокопроизводительной моделью параллельных вычислений. В ранних версиях это было совсем не так. Дмитрий Вьюков, один из ключевых разработчиков Go, указал на множество проблем с реализацией планировщика в работе «Масштабируемый дизайн планировщика Go»: «В целом, планировщик может препятствовать пользователям использовать идиоматический мелкозернистый параллелизм там, где производительность имеет решающее значение». Позвольте объяснить, что он имел ввиду.

Во-первых, глобальная очередь выполнения являлась узким местом для производительности. При создании горутины, потокам приходилось получать блокировку, чтобы поместить ее в глобальную очередь. Аналогично, когда потоки хотели взять горутину из глобальной очереди, им также приходилось получать блокировку. Как известно, блокировка не бесплатна, она сопряжена с накладными расходами, связанными с конкуренцией за блокировку (lock contention). Конкуренция за блокировку приводит к снижению производительности, особенно в сценариях с высокой степенью параллелизма.

Во-вторых, потоки часто передают связанную с ними горутину другому потоку. Это приводит к плохой локальности и чрезмерным накладным расходам на переключение контекста. Дочерняя горутина обычно хочет взаимодействовать со своей родительской горутиной. Поэтому запуск дочерней горутины в том же потоке, что и родительской, обеспечивает более высокую производительность.

В-третьих, поскольку Go использует кэширование потоков с помощью Malloc (Thread-caching Malloc), каждый поток имеет свой локальный кэш mcache, который он может использовать для выделения или удержания свободной памяти. Хотя mcache используется только потоками, выполняющими код Go, он также подключается к потокам, блокирующимся в системных вызовах, которые его вообще не используют. Кэш может занимать до 2 МБ памяти и не освобождается до тех пор, пока поток не будет уничтожен. Поскольку соотношение потоков, выполняющих код, и всех потоков может достигать 1:100 (слишком много потоков блокируются в системных вызовах), это может привести к чрезмерному потреблению ресурсов и плохой локальности данных.

❯ Улучшение планировщика


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

1. Введение локальной очереди


Каждый поток M обладает локальной очередью выполнения для хранения выполняемых горутин. Когда выполняющаяся горутина G в потоке M запускает новую горутину G1 с помощью ключевого слова go, горутина G1 добавляется в локальную очередь выполнения потока M. Если локальная очередь заполнена, G1 помещается в глобальную очередь. При выборе горутины для выполнения, M сначала проверяет свою локальную очередь, затем обращается к глобальной очереди. Таким образом, это предложение решает первую и вторую проблемы, описанные выше.



Однако это не решает третью проблему. Когда множество потоков блокируются в системных вызовах, mcache остаются подключенными, что приводит к высокому потреблению памяти самим планировщиком, не говоря уже о потреблении памяти нашей программой.

Это также создает еще одну проблему с производительностью. Чтобы избежать «голодания» (starving) горутин в локальной очереди заблокированного потока, как M1 на рисунке выше, планировщик должен разрешить другим потокам забирать (steal - красть) горутины из него. Однако при большом количестве заблокированных потоков, сканирование их всех для поиска непустой очереди выполнения становится дорогим.

2. Внедрение логического процессора


Это предложение описано в книге «Масштабируемый дизайн планировщика Go», где вводится понятие логического процессора (logical processor) P. Логический означает, что P притворяется выполняющим код, хотя на самом деле это осуществляет связанный с ним поток M. Локальная очередь потока и mcache теперь принадлежат P.

Это эффективно решает третью проблему. Поскольку mcache теперь привязан к P, а не к M, а M отсоединен от P, когда G выполняет системный вызов, потребление памяти остается низким при большом количестве M, выполняющих системные вызовы. Также, поскольку количество P ограничено, механизм перехвата (stealing) горутин является эффективным.



С появлением логических процессоров многопоточная модель M:N в Go стала именоваться моделью GMP, поскольку в ней задействовано три типа сущностей: горутина, поток и процессор.

❯ Модель GMP

Горутина g


Когда за ключевым словом go следует вызов функции, создается новый экземпляр g, именуемый G. G — это объект, представляющий горутину, содержащий метаданные, такие как состояние выполнения, стек и программный счетчик (program counter), указывающий на соответствующую функцию. Таким образом, выполнение горутины означает запуск функции, на которую ссылается G.

Когда горутина выполняется, она не уничтожается. Она становится мертвой (dead) и помещается в свободный список текущего процессора P. Если свободный список P полон, мертвая горутина помещается в глобальный свободный список. При создании новой горутины планировщик пытается повторно использовать одну из свободного списка, вместо создания новой с нуля. Этот механизм значительно удешевляет создание горутин.

Рисунок и таблица ниже описывают жизненный цикл горутин в модели GMP. Некоторые состояния и переходы опущены для простоты. Операции переходов будут описаны позже.

Состояние​

Описание​

Idle

Только что создана и еще не инициализирована​

Находится в очереди на выполнение и готова к запуску​

Не находится в очереди и в данный момент выполняет код​

Выполняет системный вызов и не исполняет пользовательский код​

Не выполняет код и не находится в очереди, например, ждет канал​

Dead

Находится в свободном списке, только что завершена или инициализируется​



Поток m


Весь код Go, будь то пользовательский код, планировщик или сборщик мусора, выполняется в потоках, управляемых ядром ОС. Для повышения эффективности работы планировщика в модели GMP вводится структура m, представляющая экземпляр потока M.

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

Каждый M также содержит ссылку на специальную горутину g0, которая запускается в системной стеке (system stack) — стеке, предоставляемом потоку ядром. В отличие от системного стека, обычный стек горутины имеет динамический размер, который увеличивается и уменьшается при необходимости. Однако, операции увеличения и уменьшения стека сами должны запускаться в валидном стеке. Для этого используется системный стек. Когда планировщику, запущенному в M, требуется управление стеком, он переключается со стека горутины на системный стек. Такие операции, как сборка мусора и парковка горутины, также выполняются в системной стеке. При выполнении такой операции, планировщик переключается на системный стек и выполняет операцию в контексте g0.

В отличие от горутин, потоки запускают код планировщика M при создании, поэтому начальным состоянием M является running. Когда M создается или пробуждается, планировщик всегда гарантирует наличие процессора P в состоянии idle, который может быть связан с M для выполнения кода. Когда M выполняет системный вызов, он отсоединяется от P, который может использоваться другим потоком M1 для продолжения его работы. Если M не может найти горутину в состоянии runnable ни в локальной, ни в глобальной очереди (netpoll), он зацикливается (spinning — вращается), пытаясь забрать горутину у другого процессора P, а затем снова заглядывает в глобальную очередь. Обратите внимание, что не все M зацикливаются, это происходит только в случае, когда количество таких потоков меньше половины занятых процессоров. Когда M нечем заняться, он засыпает и ждет, когда его возьмет другой процессор P1.

Рисунок и таблица ниже описывают жизненный цикл потоков в модели GMP. Некоторые состояния и переходы опущены для простоты. Spinning — это вид idle, когда поток потребляет циклы центрального процессора (ЦП, CPU) для выполнения кода среды выполнения, который «заимствует» горутину. Операции переходов будут описаны позже.

Состояние​

Описание​

Running​

Выполняет код среды выполнения или пользовательский код​

Syscall​

В данный момент выполняет системный вызов (заблокирован)​

Spinning​

Берет (крадет) горутину у других процессоров​

Sleep​

Находится в состоянии сна, не потребляет ресурсы процессора​



Процессор p


Структура p концептуально представляет физический процессор для выполнения горутин. Экземпляры p называются P и создаются на этапе начальной загрузки (bootstrap) программы. Хотя количество создаваемых потоков может быть большим (10 000 в Go 1.24), количество процессов обычно является небольшим и определяется GOMAXPROCS. Существует ровно GOMAXPROCS процессоров, независимо от их состояния.

Для минимизации конфликтов блокировок в глобальной очереди выполнения, каждый процессор P в среде выполнения Go поддерживает локальную очередь выполнения. Локальная очередь на самом деле состоит из двух компонентов: runnext, содержащий одну приоритетную горутину, и runq — очередь горутин. Оба компонента выступают источником runnable горутин для P, но runnext существует исключительно как оптимизация производительности. Планировщик позволяет P брать горутины из локальной очереди другого процессора P1. runnext процессора P1 исследуется только в том случае, если первые три попытки заимствовать горутину из runq были неудачными. Поэтому, когда P хочет выполнить горутину, возникает меньше конфликтов блокировок, если он сначала обращается к своему runnext.

Компонент runq — это массив фиксированного размера с циклической структурой. Массив фиксированного размера с 256 слотами обеспечивает лучшую локальность кэша и снижает накладные расходы на выделение памяти. Фиксированный размер безопасен для локальных очередей P, поскольку у нас также есть глобальная очередь в качестве резервной. Циклическая структура позволяет эффективно добавлять и удалять горутины без необходимости перемещения элементов.

mcache служит интерфейсом модели кэширования потоков Malloc и используется P для выделения микро (micro) и маленьких (small) объектов. pageCache, с другой стороны, позволяет распределителю (allocator) получать страницы памяти без получения блокировки кучи, что повышает производительность при высоком параллелизме.

Для корректной работы программы с задержками (sleep), таймаутами и интервалами, P также управляет таймерами, реализованными с помощью min-heap, где ближайший таймер находится на вершине кучи. При поиске готовой к запуску горутины, P также проверяет наличие истекших таймеров. Если таковые имеются, P добавляет соответствующую горутину в свою локальную очередь, что она могла быть запущена.

Рисунок и таблица ниже описывают жизненный цикл процессоров в модели GMP. Некоторые состояния и переходы опущены для простоты. Операции переходов будут описаны позже.

Состояние​

Описание​

Idle

Не выполняет код среды выполнения или пользовательский код​

Связан с M, который выполняет пользовательский код​

Связан с M, который выполняет системный вызов​

Связан с M, который «остановил мир» (stop-the-world) для сборки мусора​

Dead

Больше не используется, ожидает повторного использования при росте GOMAXPROCS




В начале выполнения программы количество процессоров в состоянии idle равно GOMAXPROCS. Когда поток M берет процессор для выполнения пользовательского кода, P переходит в состояние running. Если текущая горутина G выполняет системный вызов, P отсоединяется от M и переходит в состояние syscall. Во время системного вызова, если P захватывается sysmon (см. раздел «Некооперативное вытеснение» ниже), он сначала переходит в состояние idle, затем передается другому потоку M1 и переходит в состояние running. Иначе, после завершения системного вызова, P подключается к последнему M и продолжает выполняться (см. раздел «Обработка системных вызовов» ниже). При сборке мусора P переходит в состояние gcStop и возвращается в предыдущее состояния после завершения сборки. Если GOMAXPROCS уменьшается во время выполнения, лишние процессоры переходят в состояние dead и повторно используются, если GOMAXPROCS позже увеличивается.

❯ Начальная загрузка программы


Для включения планировщика Go, он должен быть инициализирован при запуске программы. Эта инициализация выполняется в ассемблере с помощью функции runtime.rt0_go. На этом этапе создаются поток M0 (основной поток) и горутина G0 (горутина системного стека M0). Также настраивается локальное хранилище потока (thread-local storage, TLS) для основного потока, где сохраняется адрес G0, что позволяет позже получить к нему доступ с помощью функции getg.

Затем вызывается функция ассемблера runtime.schedinit, реализацию которой на Go можно найти здесь. Эта функция выполняет несколько инициализаций, в частности, вызывает функцию procresize, которая устанавливает GOMAXPROCS логических процессоров P в состояние idle. Основной поток M0 подключается к первому процессору, переводя его из состояния idle в состояние running для выполнения горутин.

Затем создается основная горутина для запуска функции runtime.main, которая служит входной точкой среды выполнения Go. Внутри runtime.main создается отдельный поток для запуска sysmon (см. раздел «Некооперативное вытеснение» ниже). Обратите внимание, что runtime.main - это не функция main, которую мы пишем. Последняя появляется в среде выполнения как main_main.

Далее, основной поток вызывает mstart для начала выполнения на M0, запуская цикл планирования, который берет и выполняет основную горутину. В runtime.main, после дополнительных шагов инициализации, управление, наконец, передается пользовательской функции main_main, и программа начинает выполнять пользовательский код.

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

Суммируя, при запуске программы есть:


  • одна горутина G, выполняющая функцию main


  • два потока — один основной M0 и другой для запуска sysmon


  • один процессор P0 в состоянии running и GOMAXPROCS-1 процессоров в состоянии idle

Основной поток M0 связывается с процессором P0 для запуска основной горутины G.

Рисунок ниже демонстрирует состояние программы при запуске. Предполагается, что GOMAXPROCS равняется 2 и только что запущена функция main. Процессор P0 выполняет основную горутину и поэтому находится в состоянии running. Процессор P1 не выполняет горутин, поэтому находится в состоянии idle. Хотя основной поток M0 связан с процессором P0 для выполнения основной горутины, другой поток M1 создан для запуска sysmon.



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

❯ Создание горутины


Go предоставляет простой API для запуска параллельного вычисления: go func() { ... } (). Под капотом среда выполнения Go выполняет множество сложных задач для этого. Ключевое слово go — это просто синтаксический сахар для функции среды выполнения newproc, которая отвечает за планирование создания новой горутины. Это функция делает три вещи: инициализирует горутину, помещает ее в очередь выполнения процессора P, на котором выполняется горутина вызывающего, пробуждает другой процессор P1.

Инициализация горутины


При вызове newproc новая горутина G создается только в случае, когда нет горутин в состоянии idle. Горутины переходят в это состояние после возврата из выполнения. Новая горутина G инициализируется 2 КБ стеком, как определено в константе среды выполнения stackMin. Кроме того, функция goexit, отвечающая за логику очистки и планирования, помещается в стек вызовов G для обеспечения ее вызова после возврата G. После инициализации G переходит из состояния dead в состояние runnable, что указывает на ее готовность к планированию для выполнения.

Помещение горутины в очередь


Как упоминалось ранее, каждый процессор P имеет очередь выполнения, состояющую из двух частей: runnext и runq. Новая горутина помещается в runnext при создании. Если в runnext уже есть горутина G1, планировщик пытается переместить G1 в runq и, если удалось, помещает G в runnext. Если runq полна, G1 вместе с половиной горутин из runq помещается в глобальную очередь выполнения для уменьшения рабочей нагрузки P.

Пробуждение процессора


При создании новой горутины, если мы стремимся к максимизации параллелизма программы, поток, на котором выполняется горутина, будит другой процессор P с помощью системного вызова futex. Сначала проверяется наличие процессоров в состоянии idle. Если такой процессор доступен, либо создается новый поток, либо пробуждается существующий для входа в цикл планирования, где он будет искать готовую к выполнению горутину.

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

Резюме


На рисунке ниже показан процесс создания горутин. Для простоты предполагается, что GOMAXPROCS равняется 2, процессор P1 еще не вошел в цикл планирования, а функция main только создает новые горутины. Поскольку горутины не выполняют системные вызовы, создается ровно один дополнительный поток M2 для соединения с процессором P1.


❯ Цикл планирования


Функция schedule среды выполнения Go отвечает за поиск и выполнения runnable горутины. Она вызывается в различных сценариях: при создании нового потока, при вызове Gosched, при парковке или вытеснении горутины, а также после выполнения системного вызова и возврата горутины.

Процесс выбора готовой к выполнению горутины сложен и будет подробно рассмотрен в отдельном разделе. После выбора, горутина переходит из состояния runnable в состояние running, что свидетельствует о ее готовности к запуску. В этот момент поток ядра вызывает функцию gogo для начала выполнения горутины.

Но почему это называется циклом? Как описывалось в разделе «Инициализация горутины», после завершения горутины вызывается функция goexit. Эта функция в конечном итоге приводит к вызову функции goexit0, которая выполняет очистку для завершившейся горутины и потворно входит в функцию schedule, возвращая цикл планирования в исходное состояние.

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



Но если основной поток застрял в цикле планирования, как процесс может завершиться? Взгляните на функцию main в среде выполнения, которая выполняется основной горутиной. После возврата main_main (синоним пользовательской функции main), выполняется системный вызов exit для завершения процесса. Вот как процесс может быть завершен и вот почему основная горутина не ждет горутины, созданные с помощью ключевого слова go.

❯ Поиск готовой к выполнения горутины


Задача потока M — найти подходящую готовую к выполнению горутину, чтобы свести к минимуму «голодание» горутин. Эта логика реализована в функции findRunnable, которая вызывается циклом планирования.

Поток M ищет готовую к выполнению горутину в следующем порядке:


  1. Проверяет доступность горутины для чтения трассировки (см. раздел «Некооперативное вытеснение»).


  2. Проверяет доступность рабочей горутины сборки мусора (см. раздел «Сборщик мусора»).


  3. Один раз в 1/61 времени проверяет глобальную очередь выполнения.


  4. Проверяет локальную очередь выполнения связанного процессора P, если M зациклен (spinning).


  5. Снова проверяет глобальную очередь.


  6. Проверяет наличие горутины, готовой к вводу-выводу, в netpoll (см. раздел «Работа netpoll»).


  7. Заимствует горутину из локальной очереди другого процессора.


  8. Снова проверяет доступность рабочей горутины сборки мусора.


  9. Снова проверяет глобальную очередь, если M зациклен.

Шаги 1, 2 и 8 предназначены только для внутреннего использования среды выполнения Go. На шаге 1 горутина для чтения трассировки используется для отслеживания выполнения программы. Мы поговорим об этом позже. Между тем, шаги 2 и 8 позволяют сборщику мусора работать параллельно с обычными горутинами. Хотя эти шаги не влияют на видимый пользователю прогресс, они необходимы для корректной работы среды выполнения.

Шаги 3, 5 и 9 не ограничиваются одной горутиной, но пытаются взять сразу группу (batch) для повышения эффективности. Размер группы рассчитывается как (размер_глобальной_очереди/количество_процессоров)+1, но ограничен несколькими факторами: не может быть превышен определенный параметр максимума и не может быть занято больше половины емкости локальной очереди P. После определения размера группы, одна горутина запускается сразу, другие помещаются в локальную очередь P. Такой подход позволяет распределить нагрузку между процессорами и снижает конкуренцию за блокировку глобальной очереди, поскольку процессоры реже к ней обращаются.

Шаг 4 немного сложнее, поскольку локальная очередь P состоиз из двух частей: runnext и runq. Сначала проверяется runnext. Если там есть горутина, она возвращается. Если runnext пуст, проверяется runq. Шаг 6 будет подробно описан в разделе «Работа netpoll».

Шаг 7 — самая сложная часть процесса. Предпринимается до четырех попыток взять горутины у другого процессора P1. Первые три раза проверяется runq. Если она не пуста, половина горутин из runq процессора P1 перемещается в runq текущего процессора P. В четвертый раз проверяется runnext P1, а затем снова runq P1.

Обратите внимание, что функция findRunnable не только ищет готовые к выполнению горутины, но также будит горутины, ушедшие спать до шага 1. После пробуждения, горутина помещается в локальную очередь процессора P, который ее выполнял, ожидая выполнения некоторым потоком M.

Если после шага 9 горутины не найдено, поток M ждет истечения ближайшего таймера в netpoll, например, пробуждения горутины (при переводе в спящий режим создается таймер). Почему netpoll связан с таймерами? Система таймеров Go сильно зависит от netpoll, как указано в этом комментарии к коду. После возврата netpoll, M повторно входит в цикл планирования для поиска готовой к выполнению горутины.

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

package main

import "time"

func main() {
go func() {
time.Sleep(time.Second)
}()

time.Sleep(2*time.Second)
}


Если у P нет таймера, поток M будет свободен (idle). P помещается в свободный список, M отправляется спать путем вызова функции stopm. Он будет спать, пока другой поток M1 не разбудит его, как правило, во время создания новой горутины. После пробуждения M повторно входит в цикл планирования для поиска и выполнения runnable горутины.

❯ Вытеснение горутин


Вытеснение (preemption) — это временное прерывание выполнения горутины для запуска других горутин для предотвращения их «голодания». Существует два типа вытеснения:


  • некооперативное (non-ciooperative) — слишком долго выполняющаяся горутина принудительно останавливается


  • кооперативное (cooperative) — горутина добровольно уступает (yield) свой процессор
Некооперативное вытеснение


Рассмотрим пример некооперативного выстеснения. У нас имеется две горутины, которые вычисляют число Фибоначчи, что представляет собой замкнутый цикл с ресурсоемкими операциями на ЦП. Для обеспечения одновременного выполнения только одной горутины максимальное количество логических процессоров GOMAXPROCS устанавливается равным 1 при запуске программы: GOMAXPROCS=1 go run main.go.

package main

import (
"runtime"
"time"
)

func fibonacci(n int) int {
if n <= 1 {
return n
}
previous, current := 0, 1
for i := 2; i <= n; i++ {
previous, current = current, previous+current
}
return current
}

func main() {
go fibonacci(1_000_000_000)
go fibonacci(2_000_000_000)

time.Sleep(3*time.Second)
}


У нас имеется только один процессор P. Что произойдет? Варианты:


  • ни одна горутина не запускается, поскольку основная функция «захватила» P


  • одна горутина выполняется, другая голодает


  • обе горутины выполняются одновременно (в данном случае конкурентно) почти магически

К счастью, Go позволяет легко понять, что происходит в планировщике. Пакет runtime/trace содержит мощный инструмент для поиска и устранения проблем Go-программ. Для его использования необходимо добавить в метод main экспорт трассировки в файл:

func main() {
file, _ := os.Create("trace.out")
_ = trace.Start(file)
defer trace.Stop()
...
}


После завершения программы используем команду go tool trace trace.out для визуализации трассировки. Мой файл trace.out можно найти здесь. На рисунке ниже горизонтальная ось показывает, какая горутина выполняется на P в определенное время. Ожидаемо существует только один логический процессор Proc 0.



Увеличив масштаб (клавиша «W») в начале линии времени, можно увидеть, что процесс начинается с main.main (функция main в пакете main), которая выполняется в основной горутине G1. Через несколько миллисекунд также на Proc 0 горутина G10 планируется для выполнения функции fibonacci, захватывая процессор и вытесняя G1.



Уменьшив масштаб (клавиша «S») и прокрутив немного вправо, можно увидеть, что G10 позже заменяется другой горутиной G9, следующим экземпляром, выполняющим функцию fibonacci. Эта горутина также выполняется на Proc 0. Запомните runtime.asyncPreempt:47, мы вернемся к этому чуть позже.

Таким образом, Go способен прерывать горутины, которые интенсивно используют ресурсы процессора. Но почему это возможно? Ведь если горутина постоянно потребляет все ресурсы процессора, как ее можно прервать? Это сложная проблема, и она долго обсуждалась в трекере ошибок Go. Проблема не решалась до версии Go 1.14, в которой было представлено асинхронное прерывание.

В среде выполнения Go есть демон, «живущий» в отдельном потоке без процессора — sysmon (от «system monitor» — анализатор системы). Когда sysmon находит горутину, которая ипользует P дольше 10 мс (константа среды выполнения forcePreemptNS), он через системный вызов tgkill указывает потоку M принудительно выстеснить выполняющуюся горутину. Да, вы правильно прочитали. Согласно странице руководства Linux, tgkill используется для отправки потоку сигнала, а не для его «убийства». Сигналом является SIGURG, причина выбора объясняется здесь.

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



В обработчике сигнала счетчик программ устанавливается на функцию asyncPreempt, позволяя приостановить выполнение горутины и создать пространство для вытеснения. В ассемблерном коде функции asyncPreempt сохраняются регистры горутины и вызывается функция asyncPreempt2 на строке 47. Вот откуда берется runtime.asyncPreempt:47 на визуализации. В asyncPreempt2 горутина g0 потока M входит в gopreempt_m для отсоединения G от M и помещения G в глобальную очередь выполнения. Поток продолжает цикл планирования, находит другую горутину и выполняет ее.

Поскольку сигнал вытеснения инициируется sysmon, но фактическое вытеснение происходит после получения сигнала потоком, этот вид вытеснения является асинхронным. Вот почему горутины могут выполняться дольше 10 мс, как G9 в примере.


Кооперативное вытеснение в ранних версиях Go


В ранних версиях Go среда выполнения сама не могла вытеснять горутины с плотными циклами, как в примере выше. Программистам приходилось указывать горутинам кооперативно отказываться от своего процессора путем вызова функции runtime.Gosched в теле цикла. На Stackoverflow есть пример и описание поведения runtime.Gosched.

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

Кооперативное вытеснение в Go 1.14+


Вам интересно, почему я не использовал fmt.Printf для проверки запуска каждой горутины в терминале? Потому что вытеснение стало бы кооперативным.

Разбор программы


Скомпилируем программу и проанализируем ее ассемблерный код. Поскольку компилятор Go применяет различные оптимизации, которые могут затруднить отладку, необходимо отключить их при сборке программы. Это можно сделать с помощью команды go build -gcflags="all=-N -l" -o fibonacci main.go.

Для разбора (disassembling) функции fibonacci я использую Delve, мощный отладчик для Go: dlv exec ./fibonacci. Находясь в отладчике, выполняем следующую команду для отображения ассемблерного кода fibonacci: disassemble -l main.fibonacci. Ассемблерный код моей программы можно найти здесь. Поскольку я компилировал программу на darwin/arm64, ваш ассемблерный код может отличаться от моего.

main.go:11 0x1023e8890 900b40f9 MOVD 16(R28), R16
main.go:11 0x1023e8894 f1c300d1 SUB $48, RSP, R17
main.go:11 0x1023e8898 3f0210eb CMP R16, R17
main.go:11 0x1023e889c 090c0054 BLS 96(PC)
...
main.go:17 0x1023e8910 6078fd97 CALL runtime.convT64(SB)
...
main.go:17 0x1023e895c 4d78fd97 CALL runtime.convT64(SB)
...
main.go:20 0x1023e8a18 c0035fd6 RET
main.go:11 0x1023e8a1c e00700f9 MOVD R0, 8(RSP)
main.go:11 0x1023e8a20 e3031eaa MOVD R30, R3
main.go:11 0x1023e8a24 dbe7fe97 CALL runtime.morestack_noctxt(SB)
main.go:11 0x1023e8a28 e00740f9 MOVD 8(RSP), R0
main.go:11 0x1023e8a2c 99ffff17 JMP main.fibonacci(SB)


MOVD 16(R28), R16 загружает значение со смещением (offset) 16 из регистра R28, содержащего структуру данных горутины g, и сохраняет это значение в регистре R16. Загруженное значение — это поле stackguard0, служащее защитой стека (stack guard) для текущей горутины. Но что такое защита стека? Возможно, вы знаете, что стек горутины может увеличиваться в размерах (growable), но как среда выполнения Go понимает, что пора это сделать? Защита стека — это специальное значение, помещаемое в конец стека. Когда указатель стека достигает этого значения, среда выполнения понимает, что стек почти заполнен и нуждается в увеличении — именно это и делают следующие три инструкции.

SUB $48, RSP, R17 загружает указатель стека горутины из регистра RPS в регистр R17 и вычитает из него 48. CMP R16, R17 сравнивает защиту стека с указателем стека, а BLS 96(PC) переходит к инструкции, находящейся на 96 инструкций впереди программы, если указатель стека меньше или равен защите стека. Почем меньше или равен (≤), а не больше или равен (≥)? Поскольку стек растет вниз, указатель стека всегда больше защиты стека.

Почему эти инструкции не отображаются в коде Go, но присутствуют в ассемблерном коде? Это происходит потому, что при компиляции компилятор Go автоматически вставляет их в пролог функции. Это применяется ко всем функциям, вроде fmt.Println, а не только к нашей fibonacci.

Через 96 инструкций выполнение достигает инструкции MOVD R0, 8(RSP), затем переходит к CALL runtime.morestack_noctxt(SB). Функция runtime.morestack_noctxt в конечном счете вызывает newstack для увеличения стека и опционально входит в gopreempt_m для запуска некооперативного вытеснения. Ключевым для кооперативного вытеснения является условие входа в gopreempt_m - stackguard0 == stackPreempt. Это означает, что горутина, желающая увеличить стек, будет вытеснена, если ее stackguard0 ранее был установлен в stackPreempt.

stackPreempt может быть установлен sysmon, если горутина выполняется дольше 10 мс. Затем горутина будет вытеснена кооперативно при вызове функции или некооперативно обработчиком сигналов (signal handler) потока, в зависимости от того, что произойдет раньше. Он также может быть установлен, когда горутина входит/выходит из системного вызова или на этапе трассировки сборщика мусора. См. вытеснение sysmon, вход/выход syscall, трассировка сборщика мусора.

Визуализация трассировки


Возвращаемся к программе, убеждаемся, что GOMAXPROCS=1, и изучаем трассировку.



Четко видно, что горутины освобождают логический процессор всего через несколько десятков микросекунд, в отличие от некооперативного вытеснения, где они могут удерживать его более 10 мс. Примечательно, что трассировка стека G9 заканчивается на fmt.Printf внутри тела цикла, демонстрируя проверку защиты стека в прологе функции. Эта визуализация точно иллюстрирует кооперативное вытеснение, при котором горутины добровольно уступают свой процессор.


❯ Обработка системных вызовов


Системные вызовы — это службы, предоставляемые ядром, доступные пользовательским программам через API. Эти службы включают фундаментальные операции, например, чтение файлов, установку соединений или выделение памяти. В Go редко требуется обращаться к системным вызовам напрямую, стандартная библиотека предоставляет высокоуровневые абстракции для упрощения этих задач.

Однако, понимание работы системных вызовов является критическим для погружения в среду выполнения Go, внутреннего устройства стандартной библиотеки и оптимизации производительности. Среда выполнения использует модель потоков M:N, оптимизированную логическими процессорами, что делает обработку системных вызовов очень интересной.

Классификация системных вызовов


В среде выполнения существует две обертки над системными вызовами: RawSyscall и Syscall. Код Go, который мы пишем, использует эти функции для выполнения системных вызовов. Каждая функция принимает номер системного вызова, его аргументы и возвращает значения и код ошибки.

Syscall обычно используется для операций непредсказуемой продолжительности, таких как чтения файла или запись ответа HTTP. Поскольку продолжительность этих операций не является детерминированной, среда выполнения должна учитывать это для обеспечения эффективного использования ресурсов. Функция координирует горутины, потоки и процессоры, позволяя среде выполнения поддерживать производительность и отзывчивость во время блокирующих системных вызовов.

Тем не менее, не все системные вызовы являются непредсказуемыми. Например, получение идентификатора процесса или текущего времени обычно происходит быстро и стабильно. Для таких операций используется RawSyscall (от «raw» — прямой). Поскольку отсутствует планирование, связь между горутинами, потоками и процессорами остается неизменной при выполнении прямых системных вызовов.

На самом деле Syscall — это RawSyscall + дополнительная логика планирования.

func Syscall(trap, a1, a2, a3 uintptr) (r1, r2 uintptr, err Errno) {
runtime_entersyscall()
r1, r2, err = RawSyscall6(trap, a1, a2, a3, 0, 0, 0)
runtime_exitsyscall()
}

Планирование в Syscall


Логика планирования реализована в функциях runtime_entersyscall и runtime_exitsyscall. Эти функции соответствуют runtime.entersyscall и runtime.exitsyscall. Связь создается при компиляции.

Перед выполнением системного вызова, среда выполнения записывает, что вызываемая горутина больше не использует ЦП. Горутина G переходит из состояния running в состояние syscall, а ее указатель стека, счетчик программ, и указатель кадра сохраняются для последующего восстановления. Затем связь между потоком M и процессором P временно отключается, и P переходит в состояние syscall. Эта логика реализована в функции runtime.reentersyscall, которая вызывается runtime.entersyscall.

Что интересно, sysmon мониторит не только процессоры, выполняющие горутины (когда P находится в состоянии running), но также процессоры, выполняющие системные вызовы (когда P находится в состоянии syscall). Если P находится в состоянии syscall дольше 10 мс, вместо некооперативного вытеснения выполняющейся горутины, происходит передача управления процессором. Это сохраняет связь между горутиной G и потоком M и подключает к этому P другой поток M1, что позволяет выполнять готовые горутины в потоке M1. Поскольку P теперь выполняет код, его статус меняется с syscall на running.

Обратите внимание, что пока системный вызов находится в процессе выполнения и независимо от того, блокируется (seize) P sysmon или нет, связь между горутиной G и потоком M сохраняется. Почему? Потому что программа Go (включая среду выполнения и пользовательский код) — это всего лишь процесс пользовательского пространства. Единственным средством выполнения, которое ядро предоставляет процессу пользовательского пространства, является поток. Именно поток отвечает за выполнение кода среды выполнения, пользовательского кода и выполнение системных вызовов. Поток M выполняет системный вызов от имени какой-либо горутины G, поэтому связь между ними сохраняется. Следовательно, даже если P блокируется sysmon, M остается заблокированным, ожидая завершения системного вызова, прежде чем сможет вызвать функцию runtime.exitsyscall.

Другой важный момент заключается в том, что пока процессор P находится в состоянии syscall, он не может быть взят другим потоком M для выполнения кода до тех пор, пока sysmon не заблокирует его или системный вызов не будет завершен. Следовательно, в случае одновременного выполнения нескольких системных вызовов программа (за исключением системных вызовов) не продвигается вперед. Именно поэтому база данных Dgraph жестко устанавливает GOMAXPROCS в значение 128, чтобы «позволить планировать больше вызовов дискового ввода-вывода».

Как описано в runtime.exitsyscall, существует два пути, которыми может пойти планировщик после завершения системного вызова: быстрый и медленный.

Быстрый путь возникает, если процессор может выполнить горутину G, только что завершившую системный вызов. Этим процессором может быть процессор, ранее выполнявший G, если он все еще находится в состоянии syscall (т.е. не был заблокирован sysmon), или любой другой процессор в состоянии idle в зависимости от того, какой найдется раньше. Обратите внимание, что после завершения системного вызова предыдущий процессор может больше не находиться в состоянии syscall, поскольку его заблокировал sysmon. Перед началом прохождения быстрого пути, G переходит из состояния syscall в состояние running.



Быстрый путь системного вызова, когда sysmon не заблокировал процессор P



Быстрый путь системного вызова, когда sysmon заблокировал процессор P

В медленном пути планировщик еще раз пытается извлечь свободный процессор. Если такой обнаружен, горутина G планируется к выполнению на нем. Иначе, G помещается в глобальную очередь выполнения, и связанный поток M останавливается функцией stopm и ожидает пробуждения для продолжения цикла планирования.

❯ Сетевой и файловый ввод-вывод


Этот опрос показывает, что 75% случаев использования Go — это разработка веб-сервисов, а 45% — статических сайтов. Это не совпадение, Go специально разработан для эффективного выполнения операций ввода-вывода, чтобы решить печально известную проблему C10K. Посмотрим, как Go обрабатывает операции ввода-вывода.

HTTP-сервер изнутри


Создать HTTP-сервер на Go невероятно просто, например:

package main

import "net/http"

func main() {
http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(200)
})

http.ListenAndServe(":80", nil)
}


Функции, вроде http.ListenAndServe и http.HandleFunc, обманчиво просты, но на самом деле они абстрагируют большую сложность низкоуровневой работы с сетью. Go полагается на множество фундаментальных операций с сокетами для управления сетевой коммуникацией.



В частности, http.ListenAndServe() использует системные вызовы socket, bind, listen и accept для создания TCP-сокетов, которые по сути являются файловыми дескрипторами. Этот метод привязывает TCP-сокет к указанному адресу и порту, «прослушивает» входящие соединения и создает новый подключенный сокет для обработки клиентских запросов. Это достигается без необходимости написания кода обработки сокетов. Аналогичным образом, http.HandleFunc() регистрирует пользовательские обработчики, абстрагируясь от низкоуровневых деталей, таких как использование системного вызова read для чтения данных и системного вызова write для записи данных в сетевой сокет.



Однако эффективно обрабатывать десятки тысяч одновременных запросов для HTTP-сервера не так-то просто. Go использует для этого несколько методов. Рассмотрим некоторые известные модели ввода-вывода в Linux и то, как Go использует их преимущества.

Блокирующий ввод-вывод, неблокирующий ввод-вывод и мультиплексирование ввода-вывода


Операция ввода-вывода может быть блокирующей или неблокирующей. Когда поток вызывает блокирующий системный вызов, его выполнение приостанавливается до тех пор, пока вызов не завершится с запрошенными данными. В отличие от этого, неблокирующий ввод-вывод не приостанавливает поток; вместо этого он возвращает запрошенные данные, если они доступны, или ошибку (EAGAIN или EWOULDBLOCK), если данные еще не готовы. Блокирующий ввод-вывод проще в реализации, но неэффективен, поскольку требует от приложения создания N потоков для N соединений. В отличие от этого, неблокирующий ввод-вывод сложнее, но при правильной реализации обеспечивает значительно лучшее использование ресурсов. Визуальное сравнение этих двух моделей представлено на рисунках ниже.



Блокирующая модель ввода-вывода



Неблокирующая модель ввода-вывода

Другая модель ввода-вывода, заслуживающая упоминания — это мультиплексирование ввода-вывода, в которой используется системный вызов select или poll для ожидания готовности одного из набора файловых дескрипторов к выполнению ввода-вывода. В этой модели приложение блокируется при выполнении одного из этих системных вызовов, а не при выполнении настоящего системного вызова ввода-вывода, такого как recvfrom, показанного на рисунке выше. Когда select сообщает о готовности сокета к чтению, приложение вызывает recvfrom для копирования запрошенных данных в буфер приложения в пользовательском пространстве.


Модель ввода-вывода в Go


Go использует комбинацию неблокирующего ввода-вывода и мультиплексирования ввода-вывода для эффективной обработки соответствующих операций. Из-за ограничений производительности select и poll (как объясняется в этой статье) Go избегает их в пользу более масштабируемых альтернатив: epoll в Linux, kqueue в Darwin и IOCP в Windows. Go представляет netpoll, функцию, которая абстрагирует эти альтернативы для обеспечения единого интерфейса для мультиплексирования ввода-вывода в разных ОС.

❯ Работа netpoll


Работа netpoll включает 4 шага:


  1. Создание экземпляра epoll в пространстве ядра.


  2. Регистрация в нем файловых дескрипторов.


  3. Опрос через него файловых дескрипторов на предмет операций ввода-вывода.


  4. Отмена регистрации файловых дескрипторов в экземпляре epoll.
Создание экземпляра epoll и регистрация горутины


Когда обработчик TCP принимает соединение, выполняется системный вызов accept4 с флагом SOCK_NONBLOCK для установки файлового дескриптора сокета в неблокирующий режим. После этого создается несколько дескрипторов для интеграции с netpoll среды выполнения Go.


  1. Создается экземпляр new.netFD для оборачивания файлового дескриптора сокета. Эта структура предоставляет высокоуровневую абстракцию для выполнения сетевых операций на нижележащем файловом дескрипторе ядра. После инициализации экземпляра net.netFD, выполняется системный вызов epoll_create для создания экземпляра epoll. Он инициализируется в функции poll_runtime_pollServerInit, которая обернута в sync.Once для обеспечения однократного выполнения. Благодаря sync.Once внутри процесса Go существует только один экземпляр epoll, который используется на протяжении всего жизненного цикла процесса.


  2. Внутри poll_runtime_pollOpen среда выполнения Go выделяет экземпляр runtime.pollDesc, содержащий метаданные планирования и ссылки на горутины, вовлеченные в ввод-вывод. Затем файловый дескриптор сокета регистрируется в списке интересов (interest list) epoll с помощью системного вызова epoll_ctl с операцией EPOLL_CTL_ADD. Поскольку epoll отслеживает файловые дескрипторы, а не горутины, epoll_ctl также связывает файловый дескриптор с экземпляром runtime.pollDesc, что позволяет планировщику определить, какую горутину следует возобновить при получении сообщения о готовности к операциям ввода-вывода.


  3. Создается экземпляр poll.FD для управления операциями чтения и записи с поддержкой опроса. Он содежит ссылку на runtime.pollDesc через poll.pollDesc (которая является просто оберткой).

В Go есть проблема с использованием одного экземпляра epoll, как описано в этом открытом вопросе. Ведутся дискуссии о том, следует ли Go использовать один или несколько экземпляров epoll, или даже другую модель мультиплексирования ввода-вывода, например, io_uring.

Из-за успешности этой модели для сетевого ввода-вывода, Go также использует epoll для файлового ввода-вывода. После открытия файла, вызывается функция syscall.SetNonblock для включения неблокирующего режима для файлового дескриптора. Затем инициализируются экземпляры poll.FD, poll.pollDesc и runtime.pollDesc для регистрации файлового дескриптора в списке интересов epoll, позволяя файловому дескриптору быть мультиплексированным.

Отношения между этими дескрипторами изображены на рисунке ниже. В то время как net.netFD, os.File, poll.FD и poll.pollDesc реализованы в пользовательском коде Go (в стандартной библиотеке), runtime.pollDesc находится в самой среде выполнения.


Опрос файловых дескрипторов


Когда горутина читает из сокета или файла, она в конечном счете вызывает метод Read poll.FD. В этом методе горутина выполняет системный вызов read для получения любых доступных данных из дескриптора файла. Если данные ввода-вывода еще не готовы, т.е. вернулась ошибка EAGAIN, среда выполнения вызывает метод poll_runtime_pollWait для парковки горутины. Тоже самое происходит, когда горутина пишет в сокет или файл, за исключением того, что Read заменяется на Write, а системный вызов read - на write. Теперь горутина находится в состоянии waiting. Когда ее файловый дескриптор готов к вводу-выводу, netpoll передает ее среде выполнения для возобновления.

В среде выполнения Go netpoll - это всего лишь функция с таким же названием. В функции netpoll используется системный вызов epoll_wait для мониторинга до 128 файловых дескрипторов за определенный промежуток времени. Этот системный вызов возвращает экземпляры runtime.pollDesc, которые были зарегистрированы ранее (как описано в предыдущем разделе) для каждого файлового дескриптора, ставшего готовым. Наконец, netpoll извлекает ссылки горутин из runtime.pollDesc и передает их среде выполнения.

Но когда именно функция netpoll вызывается? Она запускается, когда поток ищет готовую к выполнению горутину, как указано в цикле планирования. Согласно функции findRunnable, среда выполнения Go обращается к netpoll только в том случае, если в локальной очереди выполнения текущего P или в глобальной очереди выполнения нет доступных горутин. Это означает, что даже если дескриптор файла готов к вводу-выводу, горутина не обязательно будет немедленно разбужена.

Как упоминалось ранее, netpoll может блокироваться на определенное время, которое определяется параметром delay. Если значение delay положительное, блокировка длится указанное количество наносекунд. Если значение delay отрицательное, блокировка продолжается до тех пор, пока не будет готово событие ввода-вывода. В противном случае, если delay равняется 0, функция немедленно возвращает все события ввода-вывода, которые в данный момент готовы. В функции findRunnable параметр delay передается со значением 0. Это означает, что если одна горутина ожидает ввода-вывода, другая горутина может быть запланирована для выполнения в том же потоке ядра.

Отмена регистрации файловых дескрипторов


Как упоминалось выше, экземпляр epoll отслеживает до 128 файловых дескрипторов. Поэтому важно отменять регистрацию файловых дескрипторов, когда они больше не нужны, иначе некоторые горутины могут оказаться в состоянии «голодания». Когда файловое или сетевое соединение больше не используется, его следует закрыть, вызвав соответствующий метод Close.

Внутри вызывается метод destroy poll.FD. Этот метод в конечном итоге вызывает функцию poll_runtime_pollClose в среде выполнения для выполнения epoll_ctl с операцией EPOLL_CTL_DEL. Это отменяет регистрацию файлового дескриптора в списке интересов epoll.

Резюме


На рисунке ниже показан весь процесс работы netpoll в среде выполнения Go с файловым вводом-выводом. Процесс для сетевого ввода-вывода аналогичен, но с добавлением обработчика TCP, принимающего и закрывающего соединения. Для простоты другие компоненты, такие как sysmon и прочие процессоры в состоянии idle, опущены.


❯ Сборщик мусора


Возможно, вы знаете, что в Go имеется сборщик мусора (garbage collector, GC) для автоматического освобождения памяти от неиспользуемых объектов. Однако, как упоминалось в разделе «Загрузка программы», при запуске программы изначально нет потоков для выполнения сборщика мусора. Так где же он на самом деле работает?

Прежде чем ответить на этот вопрос, давайте кратко рассмотрим, как работает сборка мусора. Go использует трассирующий (tracing) сборщик мусора, который идентифицирует активные/живые (live) и неактивные/мертвые (dead) объекты, обходя выделенный граф объектов, начиная с набора корневых ссылок (root references). Объекты, достижимые из корневых ссылок, считаются активными, недостижимые — неактивными и подлежат освобождению ресурсов.

Сборщик мусора использует трехцветный алгоритм маркировки с поддержкой слабых ссылок. Такая конструкция позволяет сборщику мусора работать параллельно с программой, значительно сокращая паузы, приводящие к остановке выполнения программы («остановке мира») (stop-the-world, STW), и повышая общую производительность.

Цикл сборки мусора можно разделить на 4 этапа:


  1. Первая STW: процесс приостанавливается, чтобы все процессоры могли перейти в безопасную точку (safe point).


  2. Маркировка: горутины GC ненадолго «отвлекают» P для маркировки достижимых объектов.


  3. Вторая STW: процесс снова приостанавливается, чтобы GC мог финализировать стадию маркировки.


  4. Очистка: процесс возобновляется, память для недоступных объектов освобождается в фоновом режиме.

Обратите внимание, что на шаге 2 горутина сборщика мусора выполняется параллельно с обычными горутинами на том же процессоре P. Функция findRunnable ищет не только обычные горутины, но также горутины GC (шаги 1 и 2).

❯ Общие функции

Получение горутины: getg


В среде выполнения Go имеется общая функция, которая используется для извлечения выполняющейся в текущем потоке ядра горутины - getg. Вы не найдете реализацию этой функции в исходном коде. Это связано с тем, что при компиляции компилятор переписывает ее вызовы в инструкции, которые извлекают горутину из локального хранилища потоков (TLS) или из регистров.

Но когда текущая горутина сохраняется в TLS, чтобы ее можно было извлечь позже? Это происходит при переключении контекста горутины в функции gogo, которая вызывается execute. Это также происходит при вызове обработчика сигналов в функции sigtrampgo.

Парковка горутины: gopark


Это широко используемая процедура в среде выполнения Go для перевода текущей горутины в состояние waiting и планирования выполнения другой горутины. Приведенный ниже фрагмент кода демонстрирует некоторые из ее ключевых аспектов.

func gopark(unlockf func(*g, unsafe.Pointer) bool, ...) {
...
mp.waitunlockf = unlockf
...
releasem(mp)
...
mcall(park_m)
}


Внутри функции releasem stackguard0 горутины устанавливается в stackPreempt для запуска последующего кооперативного вытеснения. Управление затем передается системной горутине g0, которая принадлежит тому же потоку, на котором выполняется горутина, для вызова функции park_m.

В park_m состояние горутины устаналивается в waiting и связь между горутиной и потоком M уничтожается. Кроме того, gopark получает функцию обратного вызова unlockf, которая выполняется в park_m. Если unlockf возвращает false, припаркованная горутина немедленно запускается снова и повторно планируется на том же потоке с помощью execute. Иначе, M входит в цикл планирования для поиска и выполнения горутины.

Запуск потока: startm


Это функция отвечает за планирование потока M для запуска определенного процессора P. Рисунок ниже показывает процесс выполнения этой функции, в котором поток M1 является предком потока M2.



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

После пробуждения, существующий поток M продолжает выполнение цикла планирования. Новый поток создается через системный вызов clone с mstart в качестве входной точки. Функция mstart входит в цикл планирования, где ищет готовую к выполнению горутину.

Остановка потока: stopm


Эта функция добавляет поток M в список свободных и усыпляет его. stopm не возвращается, пока M не будет разбужен, как правило, при создании новой горутины. Это достигается с помощью системного вызова futex, благодаря чему M не потребляет ресурсы процессора в период ожидания.

Передача управления процессором: handoff


handoff отвечает за передачу процессора P от потока M, заблокированного в системном вызове, потоку M1. P будет связан с M1 для дальнейшего выполнения путем вызова startm при определенных условиях: если глобальная очередь выполнения не пуста, если локальная очередь выполнения не пуста, если есть работа по трассировке или сборке мусора или если в данный момент ни один поток не обрабатывает netpoll. Если ни одно из этих условий не выполняется, P возвращается в список свободных процессоров.

❯ API среды выполнения Go


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

GOMAXPROCS


Эта функция устанавливает количество процессоров в среде выполнения, что определяет уровень параллелизма программы. Дефолтным значением GOMAXPROCS является значение функции runtime.NumCPU, которая запрашивает у ОС информацию о выделенных процессорах для процесса Go.

Дефолтное значение GOMAXPROCS может быть проблематичным, особенно в контейнеризованных средах, как описано в этом замечательном посте. Сейчас обсуждается предложение о том, чтобы функция GOMAXPROCS учитывала ограничения квот (quota limits) cgroup для ЦП, что призвано улучшить ее поведение в таких средах. В будущих версиях Go функция GOMAXPROCS может быть признана устаревшей, как отмечено в официальной документации: «Эта функция будет удалена после улучшения планировщика».

Некоторым программам, интенсивно использующим ввод-вывод, может быть полезно иметь большее количество процессоров, чем предусмотрено по умолчанию. Например, в базе данных Dgraph значение GOMAXPROCS жестко задано равным 128, что позволяет планировать больше операций ввода-вывода.

Goexit


Эта функция корректно завершает текущую горутину. Все отложенные вызовы выполняются до завершения горутины. Программа продолжает выполнение других горутин. Если все остальные горутины завершаются (exit), программа падает (crashe). Goexit следует использовать в тестировании, а не в реальных приложениях, когда необходимо прервать тестовый случай досрочно (например, если не выполнены предварительные условия), но при этом требуется выполнение отложенной очистки.

❯ Заключение


Планировщик задач Go — это мощная и эффективная система, обеспечивающая легковесную параллельность с помощью горутин. В этой статье мы рассмотрели его эволюцию от примитивной модели до архитектуры GMP и ключевые компоненты, такие как создание горутин, вытеснение, обработка системных вызовов и интеграция с netpoll.

Надеюсь, эти знания позволят вам писать более эффективные и надежные программы на Go.

Ссылки



Перед оплатой в разделе «Бонусы и промокоды» в панели управления активируйте промокод и получите кэшбэк на баланс.
 
Яндекс.Метрика Рейтинг@Mail.ru
Сверху Снизу