В этой статье я хочу объяснить разницу между stackless и stackful корутинами: чем они отличаются, какие у них плюсы и минусы, а также в общих чертах рассказать, как в некоторых языках программирования реализована многопоточность.
Я стараюсь писать статьи простым и понятным языком, чтобы их мог понять как можно больший круг читателей. Поэтому в тексте будут сделаны упрощения.
Начнём
В операционной системе есть процессы — это экземпляры программ, у которых есть своё виртуальное адресное пространство и ресурсы. Например, процессом может быть ваш браузер, в котором вы читаете статьи, или приложение электронной почты.
Поток — это легковесный процесс. У каждого процесса может быть один или несколько потоков — это минимальная единица выполнения внутри процесса.
Основные элементы потока:
Стек (место, где хранятся временные данные),
Регистры процессора (для хранения текущих вычислений),
ID потока (идентификатор),
Указатель на инструкцию, которая должна выполниться,
Состояние потока (например, "готов к выполнению" или "ожидание").
Обычно потоки реализуются на уровне операционной системы (такие потоки называются потоками ядра), но у этого подхода есть недостатки:
-
Высокая стоимость переключения контекста:
Переключение между потоками требует сложных операций и занимает много ресурсов, что замедляет выполнение программы.
-
Зависимость от ядра:
Если поток блокируется (например, ожидает завершения ввода-вывода), то ядро передаёт управление другому потоку. Эффективность работы зависит от того, насколько хорошо ядро управляет потоками.
-
Ограниченная масштабируемость:
Если процесс создаёт много потоков, это увеличивает нагрузку на операционную систему и её планировщик, что может привести к снижению производительности.
Для решения этих проблем были созданы потоки в пользовательском пространстве
В этом подходе управление потоками выполняется не операционной системой, а на уровне пользовательских библиотек. Это позволяет переключаться между потоками без участия ядра ОС, что делает процесс более быстрым.
Есть разные виды потоков в пользовательском пространстве, такие как fibers, green threads и корутины (именно о них пойдёт речь в этой статье).
Модель корутин поближе
В общих чертах, корутины — это специальные функции, которые позволяют приостановить выполнение в одном месте и продолжить его с того же места позже, что делает их очень удобными в асинхронных операциях и для эффективного использования ресурсов. Обычные функции такого не умеют: они выполняются до конца, не останавливаясь. Основная задача корутин — помогать управлять многозадачностью и выполнять разные задачи по очереди, но без сложностей, связанных с потоками.
У корутин есть несколько моделей мультиплексирования. Это происходит, когда для одного потока операционной системы создаются один или несколько потоков внутри пользовательского пространства.
Существуют три основные модели мультиплексирования потоков внутри пользовательского процесса: 1:1, 1:N, M:N. Основная идея этих моделей заключается в том, что они позволяют делить один поток операционной системы между несколькими потоками пользовательского пространства.
-
Модель 1:1:
Для каждого потока операционной системы создаётся один стек в пользовательском пространстве. Это означает, что количество потоков в пользовательском пространстве соответствует количеству потоков в операционной системе.
-
Модель 1:N:
Эта модель характеризуется наличием нескольких стеков в пользовательском пространстве при наличии только одного потока в операционной системе. Это позволяет нескольким потокам пользовательского пространства использовать один поток ОС.
-
Модель M:N:
В этой модели для N потоков в операционной системе создаётся M стеков в пользовательском пространстве. Это позволяет более гибко распределять ресурсы между потоками.
Существуют два основных подхода для реализации корутин: stackful и stackless.
-
Stackful корутины:
Эти корутины имеют собственный стек. Это значит, что сами корутины управляют своим выполнением, а не зависят от операционной системы. Благодаря наличию собственного стека, такие корутины могут хранить больше информации: они запоминают не только текущее состояние функции, но и иерархию вложенных вызовов других функций, которые были вызваны внутри этой корутины. Это позволяет, например, приостановить выполнение одной корутины на любом уровне вызова функций и передать управление другой корутине.
-
Stackless корутины:
В отличие от stackful корутин, stackless корутины не имеют собственного стека. Их выполнение управляется как машина состояний (state machine), которую генерирует компилятор. Это выглядит так, как будто выполнение функции просто переключается между разными состояниями — например, между ожиданием данных и продолжением выполнения после их получения.
На практике stackless корутины чаще всего реализуются через ключевые слова вроде
async
/await
. Когда функция встречаетawait
, она приостанавливается до тех пор, пока не будет готов результат, после чего продолжает выполнение.
Переключение между корутинами
Как уже было упомянуто, корутины экономят значительное количество ресурсов при переключении по сравнению с потоками операционной системы. Переключение между корутинами не требует участия ядра операционной системы, что значительно снижает затраты ресурсов. Давайте подробнее рассмотрим, что происходит в момент переключения контекста.
Переключение между stackful корутинами
Начнём со stackful корутин. У них есть стек, который хранит всю необходимую информацию. Перед тем как передать управление другой корутине, необходимо сохранить стек, а также локальные переменные, адрес возврата и другую дополнительную информацию. Затем корутина передаёт управление планировщику, который выбирает, какую следующую корутину вызвать.
При переключении между stackful корутинами важно сохранять и восстанавливать весь стек и контекст выполнения (например, регистры процессора). Это увеличивает количество операций, которые нужно выполнить перед переключением контекста. Когда корутина возобновляется, она загружает свои данные из памяти и начинает выполнение с того места, где остановилась.
Переключение между stackless корутинами
Stackless корутины работают по схожему принципу, но хранят лишь минимальное количество данных о своём состоянии. Это может быть информация о локальных переменных и о том, где корутина сделала паузу, что обычно хранится в виде программного счётчика.
Поскольку у stackless корутин нет выделенного стека вызовов, накладные расходы на переключение контекста очень малы. Когда корутина хочет отдать управление, она передаёт его планировщику. Планировщик обновляет её состояние на "ожидание" и возобновляет выполнение другой корутины, состояние которой переходит в "выполнение".
Stackful корутины более нагромождённые из-за наличия стека, но обладают большим функционалом по сравнению со stackless корутинами.
Stackful корутины имеют свой собственный стек, как уже упоминалось ранее. Это значит, что на стек выделяется память, которая зависит от реализации, но в среднем составляет от 64 КБ до 1 МБ на корутину. Кроме того, необходимо учитывать дополнительные затраты на хранение регистров и адреса возврата. Однако по сравнению со стеком этот оверхед незначителен.
Для stackless корутин нужно сохранять только локальное состояние, что составляет примерно 2 КБ на корутину. У них нет ограничений в виде стека, что делает их очень легковесными.
Например, если взять систему, состоящую из 10 000 корутин, которые потребляют по 64 КБ на стек, то общее потребление памяти составит около 640 МБ. В то же время для stackless корутин общее потребление памяти будет около 20 МБ, в расчете, что они потребляют в среднем по 2 КБ памяти.
Немного о том, как реализован стек
Существует два основных подхода: фиксированный и динамический (сегментированный) стек.
1. Фиксированный стек
При использовании фиксированного стека каждая корутина выделяет фиксированный объём памяти во время своего создания. У этого стека предопределён максимальный размер, который остаётся неизменным на протяжении всей жизни корутины.
Преимущества:
Простота реализации: не требуется дополнительная логика для увеличения или уменьшения стека по мере его использования.
Недостатки:
Если размер стека выбран слишком большим, пользователь может не использовать всю выделенную память, тратя ресурсы впустую.
Если размер стека слишком мал, существует риск переполнения, что может привести к сбою программы (крашу).
2. Динамический стек
В корутинах с динамическим (сегментированным) стеком его размер начинается с небольшого объёма и увеличивается по мере необходимости, выделяя дополнительную память. Новые сегменты памяти выделяются и связываются, когда требуется больше места в стеке.
Преимущества:
Динамическое изменение размера позволяет экономнее распределять ресурсы системы.
Недостатки:
Увеличивается сложность реализации логики выделения памяти.
Возникает небольшой оверхед по производительности для инициализации новых сегментов.
Проблема фрагментации: стек может сильно фрагментироваться, если состоит из множества маленьких сегментов, разбросанных по всей памяти.
Динамический стек позволяет гибко определять его размер, уменьшая и увеличивая его по мере необходимости, что делает его более эффективным для использования ресурсов.
Где используются корутины?
Разные языки программирования реализуют корутины по-своему, предоставляя разработчикам различные механизмы для работы с асинхронностью.
В этой таблице мы собрали топ-10 языков программирования и указали, какие типы корутин используются в каждом из них: stackfull или stackless. Это поможет вам лучше понять, как разные языки подходят к реализации корутин и какие преимущества они предлагают.
ЯП |
Тип корутин |
Описание |
---|---|---|
Python |
Stackless |
Использует |
Go |
Stackfull |
Использует "goroutines", которые динамически увеличиваются по мере необходимости. |
JavaScript |
Stackless |
Использует |
Kotlin |
Stackless |
Поддерживает корутины с использованием |
Rust |
Stackless |
Реализует |
Lua |
Stackfull |
Реализует корутины с помощью |
C# |
Stackless |
Использует |
Ruby |
Stackfull |
Использует |
Scala |
Stackless |
Реализует |
Swift |
Stackless |
Использует |
Заключение
Корутины представляют собой мощный инструмент для управления многозадачностью и асинхронным выполнением в современных языках программирования. Они обеспечивают более легковесный подход к выполнению параллельных задач по сравнению с традиционными потоками, позволяя разработчикам эффективно использовать ресурсы и оптимизировать производительность приложений.
Мы рассмотрели два основных типа корутин — stackful и stackless — и узнали об их характеристиках, преимуществах и недостатках. Stackful корутины, такие как goroutines в Go, предлагают богатые возможности благодаря своему динамическому стеку, в то время как stackless корутины, реализуемые в других языках, обеспечивают быстрые переключения между задачами с минимальными накладными расходами.
Так же подписывайтесь на мой ТГ канал, там я стараюсь понятым языком писать о технологиях, в небольших постах.
Комментарии (24)
lorc
15.10.2024 21:39Перед тем как передать управление другой корутине, необходимо сохранить стек
Зачем его сохранять то? Он и так лежит в памяти.
Я не могу сказать за все имплементаци, но почти везде я видел один и тот же механизм переключения контекста. Специальная функция сохраняет на стек регистры общего назначения, потом кладет в специальное место
sp
(он же указатель на вершину стека), затем вызывается скедулер, который возвращаетsp
от следующей корутины. Вся та же функция вычитывает регистры со стека и просто делаетret
, возвращая управление в следующую корутину.
Соответственно - накладных расходов тут - только на хранение регистров общего назначения. На aarch64 - это типа 32 регистра по 64 бита, итого 256 байт. Ну еще и регистры FPU если он используется. Хотя, в случае с FPU как раз может набежать довольно большое количество памяти, ибо регистров у них дофига и они длинные (типа, 256- или 512-битные регистры AVX).unreal_undead2
15.10.2024 21:39ибо регистров у них дофига и они длинные (типа, 256- или 512-битные регистры AVX).
Да, операционка по крайней мере по флагам может определить, использовал ли поток эти регистры в своём кванте и соптимизировать переключение контекста.
rukhi7
15.10.2024 21:39Зачем его сохранять то? Он и так лежит в памяти.
каждому модному слову в IT уважающий себя IT-шник должен придумать свое определение и свой набор проблем связанных с этим модным словом. Вроде как, чем больше и чем замудреннее проблемы и набор использованных терминов для них, тем круче IT-шник.
unreal_undead2
15.10.2024 21:39А как в разных языках/реализациях (в основном C++ интересует) обрабатывается попытка переключиться на другую stackless корутину из вложенных функций? Падение из-за перетёртого стека или явно выявляется ошибка?
alexac
15.10.2024 21:39В C++ это невозможная ситуация. Корутина — это стейт-машина, сгенерированная компилятором. Переключение в эту корутину — вызов функции. Переключение из этой корутины — сохранение состояния и возврат из этой функции. Соответственно, только функция верхнего уровня конвертируется в стейт машину и может прервать свою корутину. Все вложенные вызовы либо должны завершиться к этому моменту, либо должны вернуть awaitable объект, который функция верхнего уровня может начать ожидать с помощью co_await. co_await/co_yield доступны только в самой корутине, но не во вложенных функциях.
unreal_undead2
15.10.2024 21:39co_await/co_yield доступны только в самой корутине, но не во вложенных функциях.
А как это делается? Это ж вроде просто функции/методы (по крайней мере до внесения в стандарт), кто мешает вызвать в другом месте.
alexac
15.10.2024 21:39Так корутины уже в стандарте. Это операторы.
Функция детектируется компилятором как корутина, если он может определить для нее promise_type. Делает он это с помощью
std::coroutine_traits<Ret, Args…>::promise_type
, но как правило достаточно, чтобы у возвращаемого типа был определен member typepromise_type
. Этот промис должен определять несколько методов, которые вызываются на разных этапах конструирования/работы корутины, и они же определяют, что происходит когда встречаются co_yield/co_await/co_return, но внутри функции этот промис не доступен. Есть только эти три оператора, каждый из которых — точка, в которой корутина прерывает свою работу (и продолжает ее позже, кроме co_return).По сути из стек-фрейма корутины компилятор генерирует анонимную структуру, которая доступна только рантайму, и доступ к которой спрятан за
coroutine_handle
. Когда мы вызываем корутину, на куче создается эта структура, потом создается промис, и у промиса спрашивается возвращаемое значение. Как правило, возвращаемое значение — awaitable тип, который хранит ссылку на промис. Дальше уже отдельный вопрос, что делает твой код с этим значением. Если уничтожить его, то уничтожится и промис и корутина, возможно даже не начав выполнение. Если начать ждать этот awaitable, скорее всего это приводит к продолжению корутины. когда корутина исполняется, у нее появляется стек, который используется для вложенных вызовов функций. Но приостановить свое выполнение корутина может только на верхнем уровне, когда стек 100% пустой. Так как фрейм самой корутины живет в куче, а приостановлена она может быть только при пустом стеке, накладные расходы на переключение контекста около нулевые — по факту там нет настоящего кода переключения контекста, это просто возврат из функции, и вызов метода промиса/awaitable объекта, чтобы определить, что происходит дальше.unreal_undead2
15.10.2024 21:39Спасибо за объяснение, добавил в закладки ) Но С++20 пока далеко не во всех проектах разрешён. Когда то использовал бустовые корутины, но они сразу были stackful.
breitman
15.10.2024 21:39В перечне языков было бы неплохо упомянуть и Java c её недавно вышедшим в свет Project Loom.
funny_falcon
15.10.2024 21:39У Ruby Fiber - это Stackful корутины. Причём используют C стэк.
А у Lua хоть и stackful, но используют стэк виртуальной машины. (Кроме LuaJIT: он, емнип, тоже C стэк использует)
Gumanoid
15.10.2024 21:39Классическая статья от Роберту Иерузалимски, где он сравнивает stackless и stackful корутины и объясняет какие им были выбраны для языка Lua «Revisiting Coroutines».
vanxant
15.10.2024 21:39Чудес не бывает, stackless корутинам всё ещё нужно где-то хранить свои данные (аргументы, переменные и тд). Это часто называется фрейм активации. Если обычные функции хранят их на стеке, то асинхронные и прочие там лямбды с замыканиями вынуждены выносить его на кучу (в динамическую память). Но если на стеке память под фрейм выделяется просто вычитанием заранее известного числа из указателя стека sp, то malloc и free это не самые дешёвые операции.
Maksimilliano Автор
15.10.2024 21:39Для stackless корутин в любом случае нужно будет сохранить значительно меньший объем памяти, грубо-говоря будет создан объект который хранит стейт корутины и локальные данные, и при переключении будет перегружаться только эта информация, в то время, когда stackfull корутине нужно будет сохранить и подгрузить весь стек в память, и если он достаточно большой, то это будет значительно дольше.
Но я согласен, чудес не бывает, и там и там нужно проделать какую-то работу. Плюс stackless из-за этой "легковесности" менее гибкие.vanxant
15.10.2024 21:39корутине нужно будет сохранить и подгрузить весь стек в память
Простите, я не понимаю, что вы имеете в виду. Сохранить и подгрузить откуда и куда?.. У всех современных процессоров, кроме семейства SPARC, нет никакой специальной внутренней памяти для хранения стека, он уже весь целиком хранится в ОЗУ. Сохранять и загружать нужно только регистр указателя на вершину стека (sp). Дальше в дело вступает обычный кэш со всеми его уровнями, но это ничем не отличается для отдельных фреймов stackless функций.
KanuTaH
15.10.2024 21:39Ну, про "сохранить и подгрузить весь стек в память", конечно, бред написан, но в целом у stackfull корутин та же проблема, что и у stackless - нужна память, причем не под один фрейм, как у stackless, а, так сказать, "на всю потенциальную глубину", причем в общем случае заранее неизвестную. И эта память так же берется откуда-нибудь из кучи. Насколько я знаю, в том же Go (могу ошибаться, не слежу за этим) был когда-то сегментированный стек (когда выделенная корутине память под стек кончалась, выделялся новый сегмент и он использовался для новых вызовов "в глубину", т.е. инкремента/декремента sp было недостаточно, нужны были трюки), а потом вроде перешли на непрерывный стек (выделяется новый кусок памяти, содержимое старого стека копируется в этот новый кусок, потом память старого стека освобождается).
qwerty19106
15.10.2024 21:39Если обычные функции хранят их на стеке, то асинхронные и прочие там лямбды с замыканиями вынуждены выносить его на кучу
Это вовсе не обязательно. Например Rust может хранить все данные на стеке, и у него stackless корутины.
vanxant
15.10.2024 21:39Раст с его борроу-чекером может себе это позволить, другие языки - нет.
KanuTaH
15.10.2024 21:39другие языки - нет.
Ну это, мягко говоря, не совсем так. В том же C++ компилятор имеет право (и периодически им пользуется) избежать динамических аллокаций при вызове корутины и разместить ее фрейм на стеке вызывающей функции, если он уверен, что фрейм корутины в конкретном случае не "переживет" эту функцию. Но каких-то гарантированных вариантов, типа как с copy elision, (пока?) нет, так что, если нужно гарантированно избежать динамических аллокаций, придется позаботиться об этом самостоятельно, механизм для этого имеется - в лице реализации кастомных операторов
new
/delete
для вашего условного кастомногоpromise_type
.vanxant
15.10.2024 21:39кастомных операторов
new
/delete
асинхронщина с фреймами на стеке? Оооо, месье знает толк в стрельбе в ногу! :)
Это прям очень, очень, очень тонкий лёд. Если ещё и с исключениями...
KanuTaH
15.10.2024 21:39Я видел как в embedded коде такое используют - правда, не на стеке, а в preallocated storage, но о выделении динамической памяти в любом случае речь не идет. В embedded ее не любят.
qwerty19106
15.10.2024 21:39Да, я embedded и имел ввиду. Могу подтвердить, что память корутин лежит в preallocated storage, а куча вообще выключена.
С помощью некоторых ухищрений можно заставить компилятор вычислить максимальный размер памяти корутины, а не выдавать каждой по N байт. При этом стек общий и невозможно переполнением стека испортить чью-то память (на самом деле можно, но это уже вопрос того где линкер помещает статические переменные, и можно настроить так чтобы было нельзя).
olivera507224
В Go реализованы Stackfull корутины? Неожиданно. Особенно если вспомнить, что минимальный размер стека горутины составляет 2 Кб, что, если верить статье, соответствует размеру стека Stackless корутин. Или у меня большой пробел в знаниях и я где-то пропустил что горутины это не корутины и где-то под капотом горутины работают поверх корутин? Кто-нибудь может ткнуть меня носом в нужную информацию?
Maksimilliano Автор
В Go используются потоки реализованные в пользовательском пространстве, у которых имеется свой стек, но там они реализованы "по-умному" и умеют изменять свой размер, чтобы не расходовать зря ресурсы системы.