В этой статье я хочу объяснить разницу между stackless и stackful корутинами: чем они отличаются, какие у них плюсы и минусы, а также в общих чертах рассказать, как в некоторых языках программирования реализована многопоточность.

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

Начнём

В операционной системе есть процессы — это экземпляры программ, у которых есть своё виртуальное адресное пространство и ресурсы. Например, процессом может быть ваш браузер, в котором вы читаете статьи, или приложение электронной почты.

Поток — это легковесный процесс. У каждого процесса может быть один или несколько потоков — это минимальная единица выполнения внутри процесса.

Основные элементы потока:

  • Стек (место, где хранятся временные данные),

  • Регистры процессора (для хранения текущих вычислений),

  • ID потока (идентификатор),

  • Указатель на инструкцию, которая должна выполниться,

  • Состояние потока (например, "готов к выполнению" или "ожидание").

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

  1. Высокая стоимость переключения контекста:

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

  2. Зависимость от ядра:

    • Если поток блокируется (например, ожидает завершения ввода-вывода), то ядро передаёт управление другому потоку. Эффективность работы зависит от того, насколько хорошо ядро управляет потоками.

  3. Ограниченная масштабируемость:

    • Если процесс создаёт много потоков, это увеличивает нагрузку на операционную систему и её планировщик, что может привести к снижению производительности.

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

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

Есть разные виды потоков в пользовательском пространстве, такие как fibersgreen threads и корутины (именно о них пойдёт речь в этой статье).

Модель корутин поближе

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

Корутины могут приостанавливать свое выполнение
Корутины могут приостанавливать свое выполнение

У корутин есть несколько моделей мультиплексирования. Это происходит, когда для одного потока операционной системы создаются один или несколько потоков внутри пользовательского пространства.

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

  • Модель 1:1:

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

  • Модель 1:N:

    Эта модель характеризуется наличием нескольких стеков в пользовательском пространстве при наличии только одного потока в операционной системе. Это позволяет нескольким потокам пользовательского пространства использовать один поток ОС.

  • Модель M:N:

    В этой модели для N потоков в операционной системе создаётся M стеков в пользовательском пространстве. Это позволяет более гибко распределять ресурсы между потоками.

Мультиплексирование корутин
Мультиплексирование корутин

Существуют два основных подхода для реализации корутин: stackful и stackless.

  1. Stackful корутины:

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

  2. 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

Использует async/await и генераторы, которые управляются планировщиком.

Go

Stackfull

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

JavaScript

Stackless

Использует async/await и промисы, без выделения фиксированного стека.

Kotlin

Stackless

Поддерживает корутины с использованием suspend функций для временной приостановки.

Rust

Stackless

Реализует async/await через механизмы, похожие на state machines.

Lua

Stackfull

Реализует корутины с помощью coroutine.create, которые имеют собственный стек.

C#

Stackless

Использует async/await с использованием состояния, делая корутины легковесными.

Ruby

Stackfull

Использует Fiber, легковесные корутины без фиксированного стека.

Scala

Stackless

Реализует Future и async/await, работающие на основе состояния.

Swift

Stackless

Использует async/await, позволяя писать асинхронный код без отдельного стека.

Заключение

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

Мы рассмотрели два основных типа корутин — stackful и stackless — и узнали об их характеристиках, преимуществах и недостатках. Stackful корутины, такие как goroutines в Go, предлагают богатые возможности благодаря своему динамическому стеку, в то время как stackless корутины, реализуемые в других языках, обеспечивают быстрые переключения между задачами с минимальными накладными расходами.

Так же подписывайтесь на мой ТГ канал, там я стараюсь понятым языком писать о технологиях, в небольших постах.

Комментарии (24)


  1. olivera507224
    15.10.2024 21:39

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


    1. Maksimilliano Автор
      15.10.2024 21:39

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


  1. lorc
    15.10.2024 21:39

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

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

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


    1. unreal_undead2
      15.10.2024 21:39

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

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


    1. rukhi7
      15.10.2024 21:39

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

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


  1. unreal_undead2
    15.10.2024 21:39

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


    1. alexac
      15.10.2024 21:39

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


      1. unreal_undead2
        15.10.2024 21:39

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

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


        1. alexac
          15.10.2024 21:39

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

          Функция детектируется компилятором как корутина, если он может определить для нее 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 объекта, чтобы определить, что происходит дальше.


          1. unreal_undead2
            15.10.2024 21:39

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


  1. breitman
    15.10.2024 21:39

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


  1. funny_falcon
    15.10.2024 21:39

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

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


    1. Maksimilliano Автор
      15.10.2024 21:39

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


  1. Gumanoid
    15.10.2024 21:39

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


  1. vanxant
    15.10.2024 21:39

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


    1. Maksimilliano Автор
      15.10.2024 21:39

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


      1. vanxant
        15.10.2024 21:39

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

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


        1. KanuTaH
          15.10.2024 21:39

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


    1. qwerty19106
      15.10.2024 21:39

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

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


      1. vanxant
        15.10.2024 21:39

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


        1. KanuTaH
          15.10.2024 21:39

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

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


          1. vanxant
            15.10.2024 21:39

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

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

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


            1. KanuTaH
              15.10.2024 21:39

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


              1. qwerty19106
                15.10.2024 21:39

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

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