Корутины в C++20

в 10:26, , рубрики: c++, C++20, coroutine

Введение

Данная статья является переводом главы из книги Райнера Гримма 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 возвращает. Генератор 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>.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_always {
    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, необработанного исключения или через halde корутины)
    • вызывается деструктор promise объекта
    • вызывается деструктор параметров функции
    • освобождается память используемая frame корутины
    • передача выполнения вызывающей сущности

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

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

Автор: Илья Подшивалов

Источник


* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js