Как стать автором
Обновить

Комментарии 28

В Go реализованы Stackfull корутины? Неожиданно. Особенно если вспомнить, что минимальный размер стека горутины составляет 2 Кб, что, если верить статье, соответствует размеру стека Stackless корутин. Или у меня большой пробел в знаниях и я где-то пропустил что горутины это не корутины и где-то под капотом горутины работают поверх корутин? Кто-нибудь может ткнуть меня носом в нужную информацию?

В Go используются потоки реализованные в пользовательском пространстве, у которых имеется свой стек, но там они реализованы "по-умному" и умеют изменять свой размер, чтобы не расходовать зря ресурсы системы.

Перед тем как передать управление другой корутине, необходимо сохранить стек

Зачем его сохранять то? Он и так лежит в памяти.

Я не могу сказать за все имплементаци, но почти везде я видел один и тот же механизм переключения контекста. Специальная функция сохраняет на стек регистры общего назначения, потом кладет в специальное место sp (он же указатель на вершину стека), затем вызывается скедулер, который возвращает sp от следующей корутины. Вся та же функция вычитывает регистры со стека и просто делает ret, возвращая управление в следующую корутину.
Соответственно - накладных расходов тут - только на хранение регистров общего назначения. На aarch64 - это типа 32 регистра по 64 бита, итого 256 байт. Ну еще и регистры FPU если он используется. Хотя, в случае с FPU как раз может набежать довольно большое количество памяти, ибо регистров у них дофига и они длинные (типа, 256- или 512-битные регистры AVX).

ибо регистров у них дофига и они длинные (типа, 256- или 512-битные регистры AVX).

Да, операционка по крайней мере по флагам может определить, использовал ли поток эти регистры в своём кванте и соптимизировать переключение контекста.

Зачем его сохранять то? Он и так лежит в памяти.

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

А как в разных языках/реализациях (в основном C++ интересует) обрабатывается попытка переключиться на другую stackless корутину из вложенных функций? Падение из-за перетёртого стека или явно выявляется ошибка?

В C++ это невозможная ситуация. Корутина — это стейт-машина, сгенерированная компилятором. Переключение в эту корутину — вызов функции. Переключение из этой корутины — сохранение состояния и возврат из этой функции. Соответственно, только функция верхнего уровня конвертируется в стейт машину и может прервать свою корутину. Все вложенные вызовы либо должны завершиться к этому моменту, либо должны вернуть awaitable объект, который функция верхнего уровня может начать ожидать с помощью co_await. co_await/co_yield доступны только в самой корутине, но не во вложенных функциях.

co_await/co_yield доступны только в самой корутине, но не во вложенных функциях.

А как это делается? Это ж вроде просто функции/методы (по крайней мере до внесения в стандарт), кто мешает вызвать в другом месте.

Так корутины уже в стандарте. Это операторы.

Функция детектируется компилятором как корутина, если он может определить для нее promise_type. Делает он это с помощью std::coroutine_traits<Ret, Args…>::promise_type, но как правило достаточно, чтобы у возвращаемого типа был определен member type promise_type. Этот промис должен определять несколько методов, которые вызываются на разных этапах конструирования/работы корутины, и они же определяют, что происходит когда встречаются co_yield/co_await/co_return, но внутри функции этот промис не доступен. Есть только эти три оператора, каждый из которых — точка, в которой корутина прерывает свою работу (и продолжает ее позже, кроме co_return).

По сути из стек-фрейма корутины компилятор генерирует анонимную структуру, которая доступна только рантайму, и доступ к которой спрятан за coroutine_handle. Когда мы вызываем корутину, на куче создается эта структура, потом создается промис, и у промиса спрашивается возвращаемое значение. Как правило, возвращаемое значение — awaitable тип, который хранит ссылку на промис. Дальше уже отдельный вопрос, что делает твой код с этим значением. Если уничтожить его, то уничтожится и промис и корутина, возможно даже не начав выполнение. Если начать ждать этот awaitable, скорее всего это приводит к продолжению корутины. когда корутина исполняется, у нее появляется стек, который используется для вложенных вызовов функций. Но приостановить свое выполнение корутина может только на верхнем уровне, когда стек 100% пустой. Так как фрейм самой корутины живет в куче, а приостановлена она может быть только при пустом стеке, накладные расходы на переключение контекста около нулевые — по факту там нет настоящего кода переключения контекста, это просто возврат из функции, и вызов метода промиса/awaitable объекта, чтобы определить, что происходит дальше.

Спасибо за объяснение, добавил в закладки ) Но С++20 пока далеко не во всех проектах разрешён. Когда то использовал бустовые корутины, но они сразу были stackful.

Воспринимайте любую stackless корутину как функцию высшего порядка которая принимает callback, который она вызовет при своем завершении. Соответственно все функции выше по стеку должны тоже принимать callback как параметр.

Вот только stackless корутины не позволяют вернуться из callback'а во внутренние функции.

В перечне языков было бы неплохо упомянуть и Java c её недавно вышедшим в свет Project Loom.

У Ruby Fiber - это Stackful корутины. Причём используют C стэк.

А у Lua хоть и stackful, но используют стэк виртуальной машины. (Кроме LuaJIT: он, емнип, тоже C стэк использует)

Вы правы.
Благодарю, поправил

Классическая статья от Роберту Иерузалимски, где он сравнивает stackless и stackful корутины и объясняет какие им были выбраны для языка Lua «Revisiting Coroutines».

Чудес не бывает, stackless корутинам всё ещё нужно где-то хранить свои данные (аргументы, переменные и тд). Это часто называется фрейм активации. Если обычные функции хранят их на стеке, то асинхронные и прочие там лямбды с замыканиями вынуждены выносить его на кучу (в динамическую память). Но если на стеке память под фрейм выделяется просто вычитанием заранее известного числа из указателя стека sp, то malloc и free это не самые дешёвые операции.

Для stackless корутин в любом случае нужно будет сохранить значительно меньший объем памяти, грубо-говоря будет создан объект который хранит стейт корутины и локальные данные, и при переключении будет перегружаться только эта информация, в то время, когда stackfull корутине нужно будет сохранить и подгрузить весь стек в память, и если он достаточно большой, то это будет значительно дольше.
Но я согласен, чудес не бывает, и там и там нужно проделать какую-то работу. Плюс stackless из-за этой "легковесности" менее гибкие.

корутине нужно будет сохранить и подгрузить весь стек в память

Простите, я не понимаю, что вы имеете в виду. Сохранить и подгрузить откуда и куда?.. У всех современных процессоров, кроме семейства SPARC, нет никакой специальной внутренней памяти для хранения стека, он уже весь целиком хранится в ОЗУ. Сохранять и загружать нужно только регистр указателя на вершину стека (sp). Дальше в дело вступает обычный кэш со всеми его уровнями, но это ничем не отличается для отдельных фреймов stackless функций.

Ну, про "сохранить и подгрузить весь стек в память", конечно, бред написан, но в целом у stackfull корутин та же проблема, что и у stackless - нужна память, причем не под один фрейм, как у stackless, а, так сказать, "на всю потенциальную глубину", причем в общем случае заранее неизвестную. И эта память так же берется откуда-нибудь из кучи. Насколько я знаю, в том же Go (могу ошибаться, не слежу за этим) был когда-то сегментированный стек (когда выделенная корутине память под стек кончалась, выделялся новый сегмент и он использовался для новых вызовов "в глубину", т.е. инкремента/декремента sp было недостаточно, нужны были трюки), а потом вроде перешли на непрерывный стек (выделяется новый кусок памяти, содержимое старого стека копируется в этот новый кусок, потом память старого стека освобождается).

Да почему менее гибкие? Или что вы имеете ввиду под "менее гибкие". Тут как раз наоборот. Для стековых корутин должен быть специальный пул потоков в котором можно делать переключение. В коде при использовании стековых корутин вы не увидите отличия от обычного линейного кода. Соответственно планировщик (нормальный планировщик) обязан продолжит выполнение на том же потоке, иначе могут быть проблемы с например мьютексами. Это накладывает большие ограничения на область применения. А без стековые вы в любой момент можете преобразовать в классическую функцию которая принимает callback для продолжения выполнения, в любом потоке, на стыке любых фрейм ворков.

Если обычные функции хранят их на стеке, то асинхронные и прочие там лямбды с замыканиями вынуждены выносить его на кучу

Это вовсе не обязательно. Например Rust может хранить все данные на стеке, и у него stackless корутины.

Раст с его борроу-чекером может себе это позволить, другие языки - нет.

другие языки - нет.

Ну это, мягко говоря, не совсем так. В том же C++ компилятор имеет право (и периодически им пользуется) избежать динамических аллокаций при вызове корутины и разместить ее фрейм на стеке вызывающей функции, если он уверен, что фрейм корутины в конкретном случае не "переживет" эту функцию. Но каких-то гарантированных вариантов, типа как с copy elision, (пока?) нет, так что, если нужно гарантированно избежать динамических аллокаций, придется позаботиться об этом самостоятельно, механизм для этого имеется - в лице реализации кастомных операторов new/delete для вашего условного кастомного promise_type.

кастомных операторов new/delete

асинхронщина с фреймами на стеке? Оооо, месье знает толк в стрельбе в ногу! :)

Это прям очень, очень, очень тонкий лёд. Если ещё и с исключениями...

Я видел как в embedded коде такое используют - правда, не на стеке, а в preallocated storage, но о выделении динамической памяти в любом случае речь не идет. В embedded ее не любят.

Да, я embedded и имел ввиду. Могу подтвердить, что память корутин лежит в preallocated storage, а куча вообще выключена.

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

Если размер стека выбран слишком большим, пользователь может не использовать всю выделенную память, тратя ресурсы впустую.

Динамическое изменение размера позволяет экономнее распределять ресурсы системы

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

Если корутины практически не используют стек, то в таком случае используется 1 стек на пачку корутин.

В целом разница между Stackful/Stackless вообще не связана с использованием памяти т.к. stackful реализуется через mmap

Зарегистрируйтесь на Хабре, чтобы оставить комментарий

Публикации

Истории