Введение
Данная статья является переводом главы из книги Райнера Гримма 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), локальных переменных, время жизни которых заканчивается до точки приостановки и локальных переменных, которые превышают время жизни точки приостановки.
Необходимо соблюсти два требования для оптимизации аллокации корутины:
- Время жизни корутины должно быть вложенным во время жизни вызывающей сущности.
- Вызывающая корутину сущность должна знать размер 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
результата
KonstantinSpb
Получается задачи в RTOS — это корутины.
x893
Это, что бы каждая кухарка могла программировать свой утюг. Вместо того, что бы заниматься своим прямым делом — чисткой тарелок.
maxzhurkin
Каждая кухарка уже может писать комментарии к статьям, даже не владея языком, на котором пишет — почему бы ей же не программировать свой утюг (кстати, зачем кухарке утюг?), не владея навыками программирования?
a-tk
Таки да! Только они со стеком.
maxzhurkin
А наличие/отсутствие стека как-то влияет на то, может или нет это нечто со стеком/без стека называться корутиной (я бы предпочёл термин сопрограмма)?
a-tk
Наличие стека позволяет потоку быть вытесненным не только на самом верхнем уровне, но и в любом вложенном вызове. Более того, специальные вызовы как раз и нужны для вытеснения.
А ещё такая техника не позволяет передавать значения вызывающему коду (хотя в данном случае вызывающий код — это только планировщик) при каждом возобновлении.