В статье рассматривается заголовочный компонент 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>.
Почему выбран такой Task?
Цель этой реализации - показать универсальный шаблон Task<T, StartPolicy>, в котором выбор поведения задаётся параметрами шаблона, а не набором отдельных несвязанных классов.
Важно: вопросы
co_await Task<T>, ожидания завершения, cancellation/unregister-протокола и scheduler-а относятся к другому уровню реализации и в этот шаблон намеренно не включены.
Параметр T выбирает форму promise object: для T == void используется void_promise, для T != void используется value_promise<T>.
Параметр StartPolicy выбирает поведение initial_suspend(): StartSuspended даёт std::suspend_always, а StartImmediately даёт std::suspend_never.
Таким образом, одна шаблонная форма Task<T, StartPolicy> задаёт return object для нескольких случаев использования coroutine-функций без дублирования основной логики владения std::coroutine_handle.
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> ); static_assert( std::is_void_v<T> || (std::is_object_v<T> && !std::is_array_v<T>), "execution_core::Task<T>: T must be void or a non-array object type" ); 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); } } 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 с обоими методами не соответствует этому ограничению.
В данной реализации, в отличие от типового примера с единственным struct promise_type, выбор фактического promise type выполняется на этапе компиляции через alias:
using promise_type = std::conditional_t< std::is_void_v<T>, void_promise, value_promise<T> >;
Следствие:
Task<void> -> void_promise Task<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 != void Task<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, для которых эти операции имеют определённое поведение по стандарту.
Отличие от cppcoro
(Близкие по тематике coroutine return/awaitable-типы есть также в Folly, libunifex и Boost.Asio).
Этот Task не является заменой cppcoro.
cppcoro предоставляет набор coroutine-примитивов: task<T>, shared_task<T>, генераторы, async-generators, awaitable-типы и функции вроде sync_wait() и when_all().
Рассматриваемый здесь Task решает более узкую задачу: он показывает минимальную структуру пользовательского return object, который:
выбирает
promise_type;хранит
std::coroutine_handle;задаёт
initial_suspend()иfinal_suspend();сохраняет результат или исключение;
уничтожает coroutine state.
В этой реализации нет co_await Task<T>, нет sync_wait(), нет when_all(), нет scheduler-а и нет thread pool. Поэтому сравнивать её с cppcoro как с библиотекой нельзя; это минимальный строительный блок для собственного execution_core.
Каркас task.hpp как части execution_core с таким Task может выглядеть так (но это уже другая и отдельная тема):
Псевдокод task.hpp
namespace execution_core { class TaskRegistry; struct TaskId { ...; }; enum class CoroutineState { ... }; struct Continuation { ...; }; struct ResumeToken { ...; }; struct StartToken { ...; }; struct ExecutionBinding { ...; }; struct CoroutineControlBlock { ...; }; struct StartImmediately {}; struct StartSuspended {}; template<typename T = void, typename StartPolicy = StartSuspended> class Task { ...; }; struct ITaskRecord { ...; }; template<typename T> struct TaskRecord final { ...; }; struct TaskEntry { ...; }; class TaskRegistry { public: TaskRegistry() = default; TaskRegistry(const TaskRegistry&) = delete; TaskRegistry& operator=(const TaskRegistry&) = delete; template<typename T> TaskId spawn(Task<T, StartSuspended>&& task); std::optional<ResumeToken> ready_to_running(Continuation continuation) noexcept; void post_resume_reconcile(ResumeToken token) noexcept; bool schedule_ready(Continuation continuation) noexcept; void record_resume_exception(ResumeToken token, std::exception_ptr ex) noexcept; bool suspend_to_ready(Continuation continuation) noexcept; std::size_t cleanup_completed() noexcept; private: std::mutex mutex_; void post_start_reconcile(StartToken token) noexcept; void record_start_exception(StartToken token) noexcept; std::uint64_t next_task_id_ = 1; std::unordered_map<std::uint64_t, TaskEntry> entries_; }; template<typename T> TaskId TaskRegistry::spawn(Task<T, StartSuspended>&& task) { ...; } } // namespace execution_core
В качестве примера использования такого Task<T, StartPolicy> в составе более широкого execution_core — приведу файл с тестами execution_core_test.cpp, который я использовал при разработке execution_core.
Источники
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_handlecppreference:
std::suspend_alwayshttps://en.cppreference.com/w/cpp/coroutine/suspend_alwayscppreference:
std::suspend_neverhttps://en.cppreference.com/w/cpp/coroutine/suspend_nevercppreference:
std::conditionalhttps://en.cppreference.com/w/cpp/types/conditionalcppreference:
std::optionalhttps://en.cppreference.com/w/cpp/utility/optionalcppreference:
std::exception_ptrhttps://en.cppreference.com/w/cpp/error/exception_ptr