Вместо return
в корутине используется co_return
, возвращающий результат. В этой заметке я хочу реализовать простую корутину с использованием co_return
.
Возможно, вам будет интересно: Хотя я уже излагал теорию, лежащую в основе корутин, мне хочется еще раз об этом написать. Мой рассказ прост и основан на личном опыте. C++20 не предоставляет конкретные корутины, вместо этого он предлагает структуру для их реализации. Эта платформа состоит из более чем 20 функций, некоторые из которых вы должны имплементировать, а другие могут быть переопределены. На основе этих функций компилятор генерирует два рабочих процесса, которые определяют поведение корутины. Упрощенно говоря, корутины в C++20 — это обоюдоострый меч. С одной стороны, они предоставляют вам огромные возможности, с другой — они довольно сложны для понимания. Я посвятил корутинам более 80 страниц в своей книге "C++20: Узнай подробности", и то еще не все объяснил.
Из моего опыта, использование простых корутин и их модификация — это самый простой — возможно, единственный — способ понять их. Именно такого подхода я придерживаюсь в следующих статьях. Я представляю простые корутины и модифицирую их. Чтобы сделать рабочий процесс наглядным, в тексте содержится много комментариев и добавлено ровно столько теории, сколько необходимо для понимания внутренней сути корутин. Мои объяснения ни в коем случае не являются исчерпывающими и предназначены только для того, чтобы послужить отправной точкой в углублении ваших знаний о корутинах.
Краткое напоминание
Если функцию можно только вызвать и вернуться обратно, то корутину можно вызвать, приостановить и возобновить, а также уничтожить приостановленную корутину.
Благодаря новым ключевым словам co_await
и co_yield
, C++20 расширяет выполнение функций C++ двумя новыми концепциями.
С помощью co_await expression
можно приостанавливать и возобновлять выполнение выражения. Если вы используете co_await expression
в функции func
, вызов auto getResult = func()
не блокируется, если результат вызова функции func()
недоступен. Вместо потребляющей ресурсы блокировки осуществляется экономящее ресурсы ожидание.
Выражение co_yield
позволяет реализовывать функции-генераторы. Генераторы — функции, которые возвращают новое значение с каждым последующим вызовом. Функция генератор является подобием потоков данных, из которых можно получать значения. Потоки данных могут быть бесконечными. Таким образом, данные концепции являются основополагающими для ленивых вычислений в C++.
Кроме того, корутина не выполняет return
своего результата, она выполняет co_return
своего результата.
// ...
MyFuture<int> createFuture() {
co_return 2021;
}
int main() {
auto fut = createFuture();
std::cout << "fut.get(): " << fut.get() << '\n';
}
В этом наглядном примере createFuture
является корутиной, поскольку в ней используется одно из трех новых ключевых слов co_return
, co_yield
или co_await
и она возвращает корутину MyFuture<int>
. Что? Вот это зачастую озадачивало меня. Название корутина (coroutine) используется для двух сущностей. Позвольте мне ввести два новых термина. createFuture
— это фабрика корутин, которая возвращает объект корутины fut
, используемый для запроса результата: fut.get()
.
Этой теории должно быть достаточно. Давайте поговорим о co_return
.
co_return
Признаться, корутина в программе eagerFuture.cpp
- это самая простая корутина, которую можно себе представить, но которая все же делает что-то значимое: она автоматически сохраняет результат своего вызова.
// eagerFuture.cpp
#include <coroutine>
#include <iostream>
#include <memory>
template<typename T>
struct MyFuture {
std::shared_ptr<T> value; // (3)
MyFuture(std::shared_ptr<T> p): value(p) {}
~MyFuture() { }
T get() { // (10)
return *value;
}
struct promise_type {
std::shared_ptr<T> ptr = std::make_shared<T>(); // (4)
~promise_type() { }
MyFuture<T> get_return_object() { // (7)
return ptr;
}
void return_value(T v) {
*ptr = v;
}
std::suspend_never initial_suspend() { // (5)
return {};
}
std::suspend_never final_suspend() noexcept { // (6)
return {};
}
void unhandled_exception() {
std::exit(1);
}
};
};
MyFuture<int> createFuture() { // (1)
co_return 2021; // (9)
}
int main() {
std::cout << '\n';
auto fut = createFuture();
std::cout << "fut.get(): " << fut.get() << '\n'; // (2)
std::cout << '\n';
}
MyFuture
ведет себя как будущее, которое выполняется немедленно (см. "Асинхронные вызовы функций"). Вызов корутины createFuture
(строка 1) возвращает будущее, а вызов fut.get
(строка 2) забирает результат связанного промиса.
Есть одно тонкое различие с будущим: возвращаемое значение корутины createFuture
доступно после ее вызова. Из-за проблем связанных с жизненным циклом корутины, она управляется std::shared_ptr
(строки 3 и 4). Корутина всегда использует std::suspend_never
(строки 5 и 6) и, таким образом, не приостанавливается ни перед запуском, ни после. Это означает, что корутина немедленно выполняется, когда вызывается функция createFuture
. Функция-член get_return_object
(строка 7) возвращает хэндл к корутине и сохраняет его в локальной переменной. return_value
(строка 8) хранит результат работы корутины, который был предоставлен co_return
2021 (строка 9). Клиент вызывает fut.get
(строка 2) и использует будущее в качестве дескриптора к промису. Функция-член get
окончательно возвращает результат клиенту (строка 10).
Вы можете подумать, что не стоит тратить усилия на имплементацию корутины, которая будет вести себя так же, как функция. Вы правы! Однако эта простая корутина является идеальной отправной точкой для написания различных вариантов реализации фьючерсов.
На этом этапе я должен добавить немного теории.
Схема работы промиса
Когда вы используете co_yield
, co_await
или co_return
в функции, то она становится корутиной, и компилятор преобразует тело функции в нечто эквивалентное следующим строкам.
{
Promise prom; // (1)
co_await prom.initial_suspend(); // (2)
try {
<function body> // (3)
}
catch (...) {
prom.unhandled_exception();
}
FinalSuspend:
co_await prom.final_suspend(); // (4)
}
Знакомы ли вам названия этих функций? Правильно! Это функции-члены внутреннего класса promise_type
. Вот шаги, которые компилятор выполняет при создании объекта корутины в качестве возвращаемого значения фабрики корутины createFuture
. Сначала он создает объект промис (строка 1), вызывает его функцию-член initial_suspend
(строка 2), выполняет тело фабрики корутины (строка 3) и, наконец, вызывает функцию-член final_suspend
(строка 4). Обе функции-члены initial_suspend
и final_suspend
в программе eagerFuture.cpp
возвращают предопределенные awaitables
(ожидающие) элементы std::suspend_never
. Как следует из названия, этот awaitable
(ожидаемый) объект никогда не приостанавливается и, следовательно, объект корутина также никогда не приостанавливается и ведет себя как обычная функция. Ожидание - это то, чего вы можете ждать. Оператору co_await
требуется awaitable
(возможность ожидания). В одном из следующих постов я напишу об awaitable
и втором рабочем процессе awaiter
.
На основании этого упрощенного рабочего процесса обещания можно сделать вывод о том, какие функции-члены нужны промису (promise_type
) как минимум:
Конструктор по умолчанию
initial_suspend
final_suspend
unhandled_exception
Конечно, это далеко не полное объяснение, но, по крайней мере, достаточное для того, чтобы получить первое представление о рабочем процессе корутин.
Что дальше?
Возможно, вы уже догадались. В своем следующем посте я использую эту простую корутину в качестве отправной точки для дальнейших экспериментов. Во-первых, я добавлю комментарии к программе, чтобы сделать ее рабочий процесс явным, во-вторых, я сделаю корутину ленивой и возобновлю ее на другом потоке.
Материал подготовлен в рамках курса «C++ Developer. Professional».
Всех желающих приглашаем на открытый урок «C++20: Обзор нововведений».
Цели занятия:
- открыть для себя дивный мир последнего стандарта, о котором ходят столько легенд и слухов;
- объяснить, почему именно такие изменения были добавлены в стандарт;
- получить список нововведений для повседневного использования.
>> РЕГИСТРАЦИЯ
mayorovp
Что-то автор упростил реализацию до такой степени, что она аж стала потоконебезопасной. Хоть бы атомик добавил...
sashagil
Автор ничего не упрощал (у вас, может быть, есть на руках более сложный пример, который автор упростил?), он предоставил относительно простую демонстрацию возможной обвязки без многопоточности. Сопрограммы хорошо работают с многопоточностью, но могут быть использованы и в простом однопоточном контексте, как представлено здесь. И так уже нужно довольно повозиться. Я этим занимался, теперь (на другом проекте) не хочу торопиться -- к сожалению, стандартная библиотека не включает удобную поддержку в C++20.