В статье рассматривается заголовочный компонент execution_core::Task, предназначенный для использования как return object coroutine-функций C++20.
Coroutine-функция в C++20 — это функция, в теле которой используется co_await, co_yield или co_return [2]. Для coroutine-функции promise type определяется через возвращаемый тип функции и std::coroutine_traits. [1]
Рассматриваемая реализация:
-
задаёт
promise_typeдляTask<void>иTask<T>приT != void; -
задаёт две политики начальной приостановки:
StartSuspendedиStartImmediately; -
хранит
std::coroutine_handle<promise_type>; -
уничтожает coroutine state через
coroutine_handle::destroy(); -
сохраняет исключение в
std::exception_ptr; -
для
Task<T>приT != voidсохраняет результат вstd::optional<T>.
Термин «модуль» далее используется в архитектурном смысле. Код является header-only компонентом. Это не C++20 module unit, так как в нём используется #pragma once, а не export module.
Почему выбран такой Task?
C++20 coroutines задают языковой механизм приостановки и возобновления coroutine body, но не задают готовый универсальный тип результата для пользовательской coroutine-функции. Coroutine-функция должна иметь return type. Для этого return type компилятор через std::coroutine_traits определяет promise_type [2].
Следовательно, пользовательский тип Task<T, StartPolicy> нужен как тип результата coroutine-функции, через который задаются:
-
тип promise object;
-
объект, возвращаемый из coroutine-функции;
-
способ получить
std::coroutine_handle; -
способ управлять lifetime coroutine state;
-
место хранения результата или исключения;
-
начальное состояние coroutine body после вызова coroutine-функции.
Без такого return object coroutine-функция не получает пользовательского объекта управления. Языковой механизм создаёт coroutine state и promise object, но пользовательскому коду нужен объект, через который этот state будет доступен и уничтожен.
В данной реализации таким объектом является Task<T, StartPolicy>. Он связывает coroutine-функцию, promise object и coroutine handle в один объект владения.
В таком построении нет претензии на оригинальность: этот вариант наверняка не является уникальным и мог быть независимо реализован другими разработчиками. Здесь он рассматривается как один из возможных минимальных вариантов пользовательского return object для C++20 coroutine-функций. Материал может быть полезен тем, кто хочет явно увидеть, как связаны promise_type, std::coroutine_handle, initial_suspend(), final_suspend() и lifetime coroutine state.
Исходный код
Исходный код Task
#pragma once#include <coroutine>#include <exception>#include <optional>#include <type_traits>#include <utility>#include <cassert>namespace execution_core {struct StartImmediately {};struct StartSuspended {};template<typename T = void, typename StartPolicy = StartSuspended>class Task { static_assert( std::is_same_v<StartPolicy, StartImmediately> || std::is_same_v<StartPolicy, StartSuspended> );private: template<typename Promise> struct base_promise { std::exception_ptr exception; Task get_return_object() noexcept { return Task{ std::coroutine_handle<Promise>::from_promise( static_cast<Promise&>(*this) ) }; } auto initial_suspend() noexcept { if constexpr (std::is_same_v<StartPolicy, StartImmediately>) { return std::suspend_never{}; } else { return std::suspend_always{}; } } std::suspend_always final_suspend() noexcept { return {}; } void unhandled_exception() { exception = std::current_exception(); } }; struct void_promise final : base_promise<void_promise> { void return_void() noexcept {} }; template<typename U> struct value_promise final : base_promise<value_promise<U>> { std::optional<U> result; template<typename V> void return_value(V&& value) { result.emplace(std::forward<V>(value)); } };public: using promise_type = std::conditional_t< std::is_void_v<T>, void_promise, value_promise<T> >; using handle_type = std::coroutine_handle<promise_type>;public: Task() noexcept = default; explicit Task(handle_type handle) noexcept : handle_(handle) {} Task(const Task&) = delete; Task& operator=(const Task&) = delete; Task(Task&& other) noexcept : handle_(std::exchange(other.handle_, {})) {} Task& operator=(Task&& other) noexcept { if (this != &other) { destroy(); handle_ = std::exchange(other.handle_, {}); } return *this; } ~Task() { destroy(); } void start() { if constexpr (std::is_same_v<StartPolicy, StartSuspended>) { if (handle_ && !handle_.done()) { handle_.resume(); } } } bool done() const noexcept { return !handle_ || handle_.done(); } void rethrow_if_exception() { if (handle_ && handle_.promise().exception) { std::rethrow_exception(handle_.promise().exception); } } handle_type native_handle() const noexcept { return handle_; } explicit operator bool() const noexcept { return static_cast<bool>(handle_); } template<typename U = T> requires (!std::is_void_v<U>) U& result() & { assert(handle_); assert(handle_.done()); rethrow_if_exception(); assert(handle_.promise().result.has_value()); return *handle_.promise().result; } template<typename U = T> requires (!std::is_void_v<U>) const U& result() const& { assert(handle_); assert(handle_.done()); if (handle_.promise().exception) { std::rethrow_exception(handle_.promise().exception); } assert(handle_.promise().result.has_value()); return *handle_.promise().result; } template<typename U = T> requires (!std::is_void_v<U>) U&& result() && { assert(handle_); assert(handle_.done()); rethrow_if_exception(); assert(handle_.promise().result.has_value()); return std::move(*handle_.promise().result); }private: void destroy() noexcept { if (handle_) { handle_.destroy(); handle_ = {}; } }private: handle_type handle_{};};} // namespace execution_core
Область ответственности Task
Task<T, StartPolicy> задаёт return object coroutine-функции.
Return object coroutine-функции создаётся через вызов promise.get_return_object(). Этот вызов предшествует вызову promise.initial_suspend() и выполняется не более одного раза [1].
В данной реализации Task не является scheduler, event loop или thread pool. Он не выбирает поток выполнения и не содержит очереди готовых coroutine handle.
Область ответственности Task состоит из следующих элементов:
-
определение
promise_type; -
получение
std::coroutine_handle<promise_type>из promise object; -
хранение coroutine handle;
-
уничтожение coroutine state;
-
доступ к результату для
Task<T>приT != void; -
хранение необработанного исключения через promise object.
Типы политики запуска
В коде определены два пустых типа: StartImmediately и StartSuspended. Они используются как значения параметра шаблона StartPolicy.
Ограничение допустимых типов задано через static_assert: StartPolicy должен быть либо StartImmediately, либо StartSuspended.
Тип по умолчанию — StartSuspended. Следовательно, Task<T> эквивалентен Task<T, StartSuspended>, а Task<> эквивалентен Task<void, StartSuspended>.
Поддерживаемые формы return type coroutine-функций:
-
для
void-результата:Task<>,Task<void>,Task<void, StartSuspended>,Task<void, StartImmediately>; -
для результата-значения при
T != void:Task<T>,Task<T, StartSuspended>,Task<T, StartImmediately>.
Почему разделены void- и value-варианты promise type
Обработка co_return определяется не самим типом Task, а правилами coroutine promise.
Для co_return; или co_return с operand типа void используется p.return_void().
Для co_return expr, где operand является braced-init-list или expression non-void type, используется p.return_value(expr-or-braced-init-list).
При этом если в scope promise type одновременно найдены имена return_void и return_value, программа является ill-formed [1].
Поэтому один общий promise type с обоими методами не соответствует этому ограничению.
В данной реализации выбор выполняется на этапе компиляции:
using promise_type = std::conditional_t< std::is_void_v<T>, void_promise, value_promise<T>>;
Следствие:
Task<void> -> void_promiseTask<T>, T != void -> value_promise<T>
То есть для Task<void> существует promise type с return_void(), а для Task<T> при T != void существует promise type с return_value(...).
base_promise
void_promise и value_promise<T> различаются способом обработки co_return.
Общими для них остаются get_return_object(), initial_suspend(), final_suspend(), unhandled_exception() и поле exception.
Поэтому общая часть вынесена в base_promise<Promise>. Это устраняет дублирование одинаковых функций promise object, но сохраняет разные фактические promise types: void_promise и value_promise<T>.
base_promise<Promise> использует фактический тип promise через параметр Promise, потому что std::coroutine_handle<Promise>::from_promise(...) должен получить ссылку именно на фактический promise object [1][3].
В get_return_object() выполняется преобразование static_cast<Promise&>(*this), после чего создаётся coroutine handle через std::coroutine_handle<Promise>::from_promise(...).
Для from_promise стандарт задаёт постусловие addressof(h.promise()) == addressof(p), где p — promise object, из которого создан handle [1].
Последовательность создания coroutine return object
Для coroutine-функции с return type Task<T> при T != void используется Task<T>::promise_type, то есть value_promise<T>.
Для coroutine-функции с return type Task<void> используется Task<void>::promise_type, то есть void_promise.
Последовательность на уровне модели C++20:
-
вызывается coroutine-функция;
-
создаётся coroutine state;
-
в coroutine state создаётся promise object;
-
вызывается
promise.get_return_object(); -
get_return_object()создаётTask; -
Taskполучаетstd::coroutine_handle<promise_type>; -
вызывается
promise.initial_suspend(); -
дальнейшее поведение зависит от результата
initial_suspend().
Эта последовательность соответствует модели coroutine body, где после получения return object выполняется co_await promise.initial_suspend() [1][2].
StartPolicy
Начальное поведение coroutine body определяется результатом promise.initial_suspend().
В этой реализации рассматривается два режима:
-
StartSuspendedзадаётinitial_suspend() -> std::suspend_always; -
StartImmediatelyзадаётinitial_suspend() -> std::suspend_never.
std::suspend_always задаёт awaitable object, у которого await_ready() возвращает false [4]. Следовательно, для Task<T, StartSuspended> coroutine body после вызова coroutine-функции остаётся в начальной точке приостановки. Запуск выполняется явно через task.start().
Такой режим нужен, когда coroutine handle должен быть сначала получен, сохранён во внешней структуре управления или передан scheduler-у, и только после этого coroutine body должна начать выполнение.
std::suspend_never задаёт awaitable object, у которого await_ready() возвращает true [5]. Следовательно, для Task<T, StartImmediately> coroutine body не останавливается в начальной точке приостановки и начинает выполнение сразу после создания return object.
StartPolicy в этой реализации задаёт не runtime-флаг, а compile-time выбор результата initial_suspend().
start()
Функция start() имеет действие только для Task<T, StartSuspended>.
Для Task<T, StartImmediately> после подстановки if constexpr тело функции не содержит вызова handle_.resume().
Для StartSuspended выполняются проверки handle_ и !handle_.done(), после чего вызывается handle_.resume().
coroutine_handle::resume() возобновляет выполнение coroutine, на которую ссылается coroutine handle [1][3].
Важно: coroutine_handle::done() имеет precondition: handle должен ссылаться на suspended coroutine. Task::done() и проверка !handle_.done() в start() корректны только при условии, что coroutine в данный момент не выполняется, а находится в suspended state.
resume() допустим только для handle, который ссылается на suspended coroutine, причём coroutine не должна находиться в final suspend point.
Роль std::suspend_always в final_suspend() этой реализации
При завершении coroutine body выполняется co_await promise.final_suspend() [1][2].
В данной реализации final_suspend() всегда возвращает std::suspend_always.
После выполнения co_return результат сохраняется внутри promise object в handle_.promise().result. Исключение сохраняется внутри promise object в handle_.promise().exception.
Метод result() читает эти данные после завершения coroutine body. Поэтому coroutine state должен существовать после завершения coroutine body до момента, когда Task вызовет handle_.destroy().
Если coroutine state был бы уничтожен до вызова result(), доступ к promise object через handle_.promise() был бы невозможен.
Сохранение результата после co_return
Для Task<T> при T != void используется value_promise<T>, внутри которого хранится std::optional<T> result.
std::optional<T> представляет объект, который либо содержит значение типа T, либо не содержит значения [7].
До выполнения co_return value объект result не содержит значения. При выполнении co_return value вызывается promise.return_value(value). В данной реализации это приводит к вызову result.emplace(std::forward<V>(value)).
Следовательно, std::optional<T> используется как storage для результата, который появляется не при создании coroutine state, а при выполнении co_return.
Обработка void-результата
Для void используется void_promise, содержащий return_void().
Для coroutine-функции с return type Task<void> или Task<> при выполнении co_return; используется return_void() [1][2].
Результат-значение в этом случае не хранится.
Доступ к результату
Методы result() существуют только для Task<T> при T != void.
В реализации это задано через constraint:
template<typename U = T>requires (!std::is_void_v<U>)
Следовательно, для Task<void> методы result() не участвуют в overload resolution, а для Task<T> при T != void доступны три overload:
U& result() &;const U& result() const&;U&& result() &&;
Они различаются ref-qualifier-ом функции-члена:
-
для lvalue-объекта
Task<T> taskвыбираетсяU& result() &; -
для const lvalue-объекта
const Task<T> taskвыбираетсяconst U& result() const&; -
для rvalue-объекта
std::move(task).result()выбираетсяU&& result() &&.
Условия корректного вызова result() в данной реализации выражены проверками:
assert(handle_);assert(handle_.done());assert(handle_.promise().result.has_value());
Следовательно, result() рассчитан на вызов после завершения coroutine body. Перед возвратом результата выполняется проверка сохранённого исключения через rethrow_if_exception().
Роль std::exception_ptr в этой реализации
Исключение, вышедшее из coroutine body, не выбрасывается наружу обычным способом в точке вызова coroutine-функции. Оно обрабатывается через promise.unhandled_exception() [1][2].
В данной реализации unhandled_exception() сохраняет исключение через exception = std::current_exception().
Позже result() вызывает rethrow_if_exception(), и сохранённое исключение повторно выбрасывается через std::rethrow_exception(...).
std::exception_ptr предназначен для хранения ссылки на объект исключения, который затем может быть повторно выброшен [8].
Для Task<T> при T != void сохранённое исключение повторно выбрасывается из result().
Для Task<void> метода result() нет; проверка сохранённого исключения выполняется явным вызовом rethrow_if_exception() после завершения coroutine body.
Владение coroutine handle
В классе хранится handle_type handle_, где handle_type — это std::coroutine_handle<promise_type>.
std::coroutine_handle является handle-типом, который ссылается на coroutine state, но сам по себе не задаёт RAII-владение этим состоянием [3].
Владение задаётся самим Task: копирование запрещено, перемещение разрешено, а деструктор вызывает destroy().
Копирование запрещено, потому что при разрешённом копировании два объекта Task могли бы хранить один и тот же coroutine handle и оба вызвать destroy() для одного coroutine state.
Перемещение разрешено, потому что при перемещении handle передаётся новому объекту, а исходный объект получает пустое значение через std::exchange(other.handle_, {}).
Следствие: в этой модели уничтожение coroutine state связано с одним объектом Task.
Условие для уничтожения coroutine state
destroy() вызывается только при наличии непустого handle. Внутри destroy() выполняется handle_.destroy(), после чего handle_ сбрасывается в пустое значение.
coroutine_handle::destroy() уничтожает coroutine state, на который ссылается handle [1][3].
Вызов handle_.destroy() в деструкторе Task имеет определённое поведение только при следующих условиях:
-
handle_не был уничтожен через копию, полученную изnative_handle(); -
coroutine не выполняется конкурентно;
-
coroutine находится в suspended state.
В стандарте указано, что вызов destroy() для coroutine, которая не находится в suspended state, приводит к undefined behavior [1].
Следовательно, владение Task, вызовы resume() и использование handle, полученного через native_handle(), должны быть согласованы внешним управляющим кодом.
native_handle()
Task сам не является scheduler-ом.
Но внешний scheduler или event loop должен иметь возможность получить coroutine handle, чтобы сохранить его и позже вызвать resume().
Для этого предоставлен метод native_handle(). Он возвращает копию std::coroutine_handle<promise_type>, но не передаёт владение coroutine state.
Уничтожение coroutine state в данной модели остаётся обязанностью объекта Task, потому что именно Task вызывает handle_.destroy().
Границы текущей реализации
Данная реализация задаёт return object coroutine-функции и lifetime coroutine state, но не задаёт awaiter protocol.
В классе отсутствуют await_ready(), await_suspend(...), await_resume() и operator co_await().
В C++ coroutine await-expression использует awaiter protocol, включающий await_ready, await_suspend и await_resume [2].
Следовательно, этот класс задаёт return object coroutine-функции Task<T> f();, но не задаёт поведение выражения co_await f().
Для поддержки co_await Task<T> требуется отдельное определение awaiter protocol.
Ограничение для ссылочных и неподдерживаемых типов результата
Для результата используется std::optional<T> result.
std::optional<T> содержит значение типа T как contained value [7].
Следовательно, данная реализация value-варианта определена только для таких T, для которых std::optional<T> является well-formed и для которых выражение result.emplace(std::forward<V>(value)) well-formed для operand конкретного co_return.
Task<T&>, Task<T[]>, Task<function-type> и другие формы, для которых std::optional<T> не может содержать contained value типа T, этой реализацией не поддерживаются.
Сводка типов
Для void-результата:
-
Task<>эквивалентенTask<void, StartSuspended>; -
Task<void>эквивалентенTask<void, StartSuspended>; -
Task<void, StartSuspended>используетvoid_promise,std::suspend_always,return_void(); -
Task<void, StartImmediately>используетvoid_promise,std::suspend_never,return_void().
Для результата-значения при T != void:
-
Task<T>эквивалентенTask<T, StartSuspended>; -
Task<T, StartSuspended>используетvalue_promise<T>,std::suspend_always,return_value(...); -
Task<T, StartImmediately>используетvalue_promise<T>,std::suspend_never,return_value(...).
Итоговая схема:
Task<> -> Task<void, StartSuspended>Task<void> -> Task<void, StartSuspended>Task<void, StartSuspended> -> void_promise, suspend_always, return_void()Task<void, StartImmediately> -> void_promise, suspend_never, return_void()Task<T> -> Task<T, StartSuspended>, T != voidTask<T, StartSuspended> -> value_promise<T>, suspend_always, return_value(...)Task<T, StartImmediately> -> value_promise<T>, suspend_never, return_value(...)
Итоговая формулировка
execution_core::Task<T, StartPolicy> — это class template, который задаёт return object для C++20 coroutine-функций.
Параметр T определяет promise type: при T == void используется void_promise, при T != void используется value_promise<T>.
Параметр StartPolicy определяет результат initial_suspend(): StartSuspended даёт std::suspend_always, а StartImmediately даёт std::suspend_never.
Coroutine handle создаётся через std::coroutine_handle<Promise>::from_promise(...) и хранится внутри Task [1][3].
Coroutine state уничтожается деструктором Task через handle_.destroy() при наличии непустого handle [1][3].
В текущей реализации Task является владельцем coroutine handle и обеспечивает доступ к promise object после завершения coroutine body. Он не задаёт awaiter protocol для co_await Task<T>.
Корректное использование этой реализации требует, чтобы операции done(), resume() и destroy() вызывались только в состояниях coroutine, для которых эти операции имеют определённое поведение по стандарту.
Источники
-
ISO/IEC 14882:2020(E), разделы:
-
Coroutine definitions;
-
Coroutine promise;
-
Coroutine handle;
-
Coroutine state lifetime.
-
-
cppreference: Coroutines https://en.cppreference.com/w/cpp/language/coroutines
-
cppreference:
std::coroutine_handlehttps://en.cppreference.com/w/cpp/coroutine/coroutine_handle -
cppreference:
std::suspend_alwayshttps://en.cppreference.com/w/cpp/coroutine/suspend_always -
cppreference:
std::suspend_neverhttps://en.cppreference.com/w/cpp/coroutine/suspend_never -
cppreference:
std::conditionalhttps://en.cppreference.com/w/cpp/types/conditional -
cppreference:
std::optionalhttps://en.cppreference.com/w/cpp/utility/optional -
cppreference:
std::exception_ptrhttps://en.cppreference.com/w/cpp/error/exception_ptr
ссылка на оригинал статьи https://habr.com/ru/articles/1031644/