Корутины в C++20. Часть 1

Введение


Данная статья является переводом главы из книги Райнера Гримма Concurrency with Modern C++, которая является более доработанной и обширной версией статьи на его сайте. Так как весь перевод не умещается в рамках данной статьи, в зависимости от реакции на публикацию, выложу оставшуюся часть.


Корутины


Корутины это функции которые могут приостановить или возобновить свое выполнение при этом сохраняя свое состояние. Эволюция функций в C++ сделала шаг вперед. Корутины с наибольшей вероятностью войдут вошли в C++20.


Идея корутин, представленная как новая в C++20, довольно стара. Понятие корутины было предложено Мелвином Конвеем. Он использовал данное понятие в публикации о разработке компиляторов от 1963. Дональд Кнут называл процедуры частным случаем корутин. Иногда должно пройти время чтобы та или иная идея была принята.


Посредством новых ключевых слов co_await и co_yield C++20 расширяет понятие выполнения функций в C++ при помощи двух новых концепций.


Благодаря co_await expression появляется возможность приостановки и возобновления выполнения expression. В случае использования co_await expression в функции func вызов auto getResult = func() не является блокирующим, если результат данной функции недоступен. Вместо потребляющей ресурсы блокировки (resourse-consuming blocking) осуществляется экономящее ресурсы ожидание (resource-friendly waiting).


co_yield expression позволяет реализовывать функции-генераторы. Генераторы — функции, которые возвращают новое значение с каждым последующим вызовом. Функция генератор является подобием потоков данных (data stream) из которых можно получать значения. Потоки данных могут быть бесконечными. Таким образом, данные концепции являются основополагающими ленивых вычислений в C++.


Функции-генераторы


Ниже представленный код упрощён до невозможности. Функция getNumbers возвращает все целые числа от begin до end с шагом inc. begin должно быть меньше end, а inc должен быть положительным.


Жадный генератор
// greedyGenerator.cpp
#include <iostream>
#include <vector>

std::vector<int> getNumbers(int begin, int end, int inc = 1) {
    std::vector<int> numbers; // (1)
    for (int i = begin; i < end; i += inc) {
        numbers.push_back(i);
    }
    return numbers;
}

int main() {
    const auto numbers = getNumbers(-10, 11);
    for (auto n : numbers) {
        std::cout << n << " ";
    }
    std::cout << "\n";

    for (auto n : getNumbers(0, 101, 5)) {
        std::cout << n << " ";
    }
    std::cout << "\n";
}

Конечно, реализация getNumbers является велосипедом, потому что может быть заменена std::iota с C++11.


Для более полного представления, вывод программы:


$ ./greedyGenerator
-10 -9 -8 -7 -6 -5 -4 -3 -2 -1 0 1 2 3 4 5 6 7 8 9 10 
0 5 10 15 20 25 30 35 40 45 50 55 60 65 70 75 80 85 90 95 100 

В данной программе есть два наиболее важных аспекта. Во-первых, вектор numbers (см. комментарий (1) в коде) всегда хранит весь набор данных. Это будет происходить даже если пользователя интересуют первые 5 из 1000 элементов вектора. Во-вторых, достаточно легко преобразовать функцию getNumbers в ленивый генератор.


Ленивый генератор
// lazyGenerator.cpp
#include <iostream>
#include <vector>

generator<int> generatorForNumbers(int begin, int inc = 1) {
    for (int i = begin; ; i += inc) { // (4)
        co_yield i; // (3)
    }
}

int main() {
    const auto numbers = generatorForNumbers(-10); // (1)
    for (int i = 1; i <= 20; ++i) { // (5)
        std::cout << numbers << " ";
    }
    std::cout << "\n";

    for (auto n : generatorForNumbers(0, 5)) { // (2)
        std::cout << n << " ";
    }
    std::cout << "\n";
}

Примечание переводчика: данный код не скомпилируется, т.к. является лишь наглядным примером использования концепций. Рабочие примеры генератора будут далее.
Для сравнения, функция getNumbers из примера greedyGenerator.cpp возвращает std::vector<int>, тогда как корутина generatorForNumbers из файла lazyGenerator.cpp возвращает generator. Генератор numbers в строке с меткой (1) или генератор generatorForNumbers(0, 5) с пометкой (2) возвращают новые значения по запросу. Range-based for инициирует запрос. Если точнее, то запрос к корутине возвращает значение i посредством co_yield i (см. метку (3)) и немедленно приостанавливает выполнение. Если запрашивается новое значение, корутина продолжает выполнение с данного конкретного места.


Выражение generatorForNumbers(0, 5) (см. метку (2)) является генератором по месту использования (just-in-place usage).


Важно обратить внимание на один аспект. Корутина generatorForNumbers создает бесконечный поток данных, потому что цикл for в строке с меткой (4) не имеет условия завершения. Данный подход не является ошибочным, т.к., например, в строке (5) осуществляется запрос конечного числа элементов. Что, однако, не справедливо для выражения в строке (2) которое будет выполняться бесконечно.


Подробности


Типичные сценарии использования


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


Основополагающие концепции


Корутины в C++20 асимметричные симметричные, первого класса (first-class) и бесстековые (stackless).
Асимметричные корутины возвращают контекст выполнения вызывающей стороне. Напротив, симметричные корутины делегируют последующее выполнение другой корутине.
Корутины первого класса идентичны функциям первого класса потому что корутины могут вести себя как данные. Аналогичное данным поведение означает, что корутины могут быть аргументами или возвращаемыми значениями функций или храниться в переменных.
Бесстековые корутины позволяют приостанавливать или возобновлять работу корутин более высокого уровня. Выполнение корутин и приостановка в корутине возвращает выполнение вызывающей стороне. Бесстековые корутины часто называют возобновляющими работу функциями (resumable functions).


Цели проектирования


Гор Нишанов описал следующие цели проектирования корутин.
Корутины должны:


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

В соответствии с такими пунктами как масштабирование и бесшовное взаимодействиу с существующими особенностями, корутины являются бесстековыми. Напротив, стековые корутины резервируют для стека по-умолчанию 1MB в Windows и 2MB в Linux.


Формирование корутин


Функция становится корутиной если использует


  • co_return
  • co_await
  • co_yield
  • co_await expression в range-based for циклах

Ограничения


Корутины не могут содержать выражение return или замещающие возвращаемые типы. Это относится как к неограниченным заместителям (auto), так и к ограниченным заместителям (концепты).


В дополнение, constexpr функции, конструкторы, деструкторы и функция main не могут быть корутинами.
Подробно про данные ограничения можно прочитать в proposal N4628.


co_return, co_yield и co_await


Корутина использует co_return для возврата значения.


Благодаря co_yield появляется возможность реализации генераторов бесконечных потоков данных из которых можно получать значения по запросу. Возвращаемый тип генератора generator<int> generatorForNumbers(int begin, int inc = 1) это generator<int> внутри которого специальный promise p такой, что вызов co_yield i является идентичным вызову co_await p.yield_value(i).co_yield i может быть вызван произвольное число раз. Сразу после вызова выполнение корутины приостанавливается.
co_await способствует тому, что выполнение корутины может быть приостановлено и возобновлено. Выражение exp в co_await exp должно являться, что называется, ожидающим выражением (далее awaitables). exp должно реализовывать специальный интерфейс, который состоит из трёх функций: await_ready, await_suspend и await_resume.
Стандарт C++20 уже имеет 2 определения awaitables: std::suspend_always и std::suspend_never.
std::suspend_always


struct suspend_always {
    constexpr bool await_ready() const noexcept { return false; }
    constexpr void await_suspend(coroutine_handle<>) const noexcept {}
    constexpr void await_resume() const noexcept {}
};

Как указано в имени, awaitable std::suspend_always приостанавливает выполнение всегда, поэтому await_ready возвращает false. Противоположная идея лежит в основе std::suspend_never.
std::suspend_never


struct suspend_never {
    constexpr bool await_ready() const noexcept { return true; }
    constexpr void await_suspend(coroutine_handle<>) const noexcept {}
    constexpr void await_resume() const noexcept {}
};

Наиболее распространенный вариант использования co_await это сервер ожидающий событий.
Блокирующий сервер


Acceptor acceptor{443};
while (true) {
    Socket socket = acceptor.accept();          // blocking
    auto request = socket.read();               // blocking
    auto response = handleRequest(request);
    socket.write(response);                     // blocking
}

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


Acceptor acceptor{443};
while (true) {
    Socket socket = co_await acceptor.accept();
    auto request = co_await socket.read();
    auto response = handleRequest(request);
    co_await socket.write(response);
}

Фреймворк


Фреймворк для написания корутин состоит из более чем 20 функций которые частично нужно реализовать, а частично могут быть переписаны. Таким образом корутины могут быть адаптированы под каждую конкретную задачу.
Корутина состоит из трех частей: promise объект, handle корутины и frame корутины.
Promise объект является объектом воздействия изнутри корутины и осуществляет доставку результата из корутины.
Handle корутины это не владеющий handle для продолжения работы или уничтожения frame корутины снаружи.
Frame корутины это внутреннее, обычно размещенное на куче состояние. Состоит из ранее упомянутого promise объекта, копий параметров корутины, представления точки приостановки (suspention point), локальных переменных, время жизни которых заканчивается до точки приостановки и локальных переменных, которые превышают время жизни точки приостановки.
Необходимо соблюсти два требования для оптимизации аллокации корутины:


  1. Время жизни корутины должно быть вложенным во время жизни вызывающей сущности.
  2. Вызывающая корутину сущность должна знать размер frame корутины.

Упрощенный workflow


При использовании в функции co_return или co_yield или co_await таковая становится корутиной и компилятор преобразует её тело в нечто похожее на представленный код.
Тело корутины


{
    Promise promise;
    co_await promise.initial_suspend();
    try {
        <тело функции>
    } catch (...) {
        promise.unhandled_exception();
    }
FinalSuspend:
    co_await promise.final_suspend();
}

Workflow состоит из следующих стадий:


  • Корутина начинает выполнение
    • аллоцирование frame корутины при необходимости.
    • копирование всех параметров функции в frame корутины.
    • создание promise объекта promise.
    • вызов promise.get_return_object() для создания handle корутины и сохранение такового в локальной переменной. Результат вызова будет возвращен вызывающей стороне при первой приостановке корутины.
    • вызов promise.initial_suspend() и ожидание co_await результата. Данный тип promise обычно возвращает suspend_never для корутин немедленного выполнения или suspend_always для ленивых корутин.
    • тело корутины выполняется начинает выполнение после co_await promise.initial_suspend()
  • Корутины достигают точки приостановки
    • возвращаемый объект promise.get_return_object() возвращается вызывающей сущности который инициирует продолжение выполнение корутины
  • Корутина достигает co_return
    • вызывается promise.return_void() для co_return или co_return expression, где expression имеет тип void
    • вызывается promise.return_value(expression) для co_return expression, где expression имеет тип отличный от void
    • удаляется весь стек созданных переменных
    • вызывается promise.final_suspend() и ожидается co_await результат
  • Корутина уничтожается (посредством завершения через co_return, необработанного исключения или через handle корутины)
    • вызывается деструктор promise объекта
    • вызывается деструктор параметров функции
    • освобождается память используемая frame корутины
    • передача выполнения вызывающей сущности

Когда корутина завершается посредством необработанного исключения происходит следующее:


  • ловится исключение и вызывается promise.unhandled_exception() из catch блока
  • вызывается promise.final_suspend() и ожидается co_await результата

Часть 2

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

    0
    Корутины это функции которые могут приостановить или возобновить свое выполнение при этом сохраняя свое состояние.


    Получается задачи в RTOS — это корутины.
      –2
      Это, что бы каждая кухарка могла программировать свой утюг. Вместо того, что бы заниматься своим прямым делом — чисткой тарелок.
        +1
        Каждая кухарка уже может писать комментарии к статьям, даже не владея языком, на котором пишет — почему бы ей же не программировать свой утюг (кстати, зачем кухарке утюг?), не владея навыками программирования?
        0
        Таки да! Только они со стеком.
          +1
          А наличие/отсутствие стека как-то влияет на то, может или нет это нечто со стеком/без стека называться корутиной (я бы предпочёл термин сопрограмма)?
            +1
            Наличие стека позволяет потоку быть вытесненным не только на самом верхнем уровне, но и в любом вложенном вызове. Более того, специальные вызовы как раз и нужны для вытеснения.
            А ещё такая техника не позволяет передавать значения вызывающему коду (хотя в данном случае вызывающий код — это только планировщик) при каждом возобновлении.
        +1
        Корутины в C++20 асимметричные [...]

        Симметричные. Вроде как в последний момент подправили:
        C++ Coroutines: Understanding Symmetric Transfer
        Symmetric transfer and no-op coroutines

          0
          Поправил в статье и сделал отсылку на данное замечание.
          +3
          Всё бы ничего, но от опечаток и издевательств над словосочетании изнутри глазей текут кровавые слёзы.
            0
            Прошу прощения. Впредь буду больше времени уделять ревью.
            +4
            Круто. Но отлаживать это я не хочу.
              0

              Вряд ли отладка сопрограмм будет сложнее отладки лапши из обратных вызовов.

              0
              Интересно, но недостаточно низкоуровнево.
              Можно ли было сделать поддержку всех вариантов (симметричные и асимметричные, стековые и бесстековые)?
              Как это соотносится с корутинами из Boost?
                +1
                У меня есть прекрасная идея статьи, которую Вы могли бы написать.
                  +1
                  Про boost по возможности сделаю статью, т.к. сейчас переделываю взаимодействие с мотор-контроллером на корутины. Про низкоуровневый анализ пока не планировал ничего делать. В дополнение, у boost есть хороший набор примеров как это всё там соотносится.
                    0
                    Будет очень интересно!
                    Я пока еще не осознал всю мощь корутин, но вот замечание по поводу недостаточной низкоуровневости реализаци возникло из того, что разработчики стандарта выбрали конкретную модель реализации и уже завязали на нее некие классы стандартной библиотеки, а не предоставили универсальный языковой механизм вроде boost.context, с помощью которого (наверное?) можно было бы реализовывать любую модель.
                      0
                      У меня после прочтения Райнера Гримма остался ряд вопросов по теме и позже я наткнулся на лекции МФТИ по корутинам. Я оставил ссылку в комментариях ко второй части перевода. И, как мне показалось, там лектор лучше раскрывает тему. Рекомендую посмотреть.

                Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

                Самое читаемое