|
|
|
Professor Seleznov
|
В статье рассматривается заголовочный компонент 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][2]. Рассматриваемая реализация:
- задаёт promise_type для Task и Task при T != void;
- задаёт две политики начальной приостановки: StartSuspended и StartImmediately;
- хранит std::coroutine_handle;
- уничтожает coroutine state через coroutine_handle::destroy();
- сохраняет исключение в std::exception_ptr;
- для Task при T != void сохраняет результат в std::optional.
Термин «модуль» далее используется в архитектурном смысле. Код является 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 [1][2]. Следовательно, пользовательский тип Task нужен как тип результата coroutine-функции, через который задаются:
- тип promise object;
- объект, возвращаемый из coroutine-функции;
- способ получить std::coroutine_handle;
- способ управлять lifetime coroutine state;
- место хранения результата или исключения;
- начальное состояние coroutine body после вызова coroutine-функции.
Без такого return object coroutine-функция не получает пользовательского объекта управления. Языковой механизм создаёт coroutine state и promise object, но пользовательскому коду нужен объект, через который этот state будет доступен и уничтожен. В данной реализации таким объектом является Task. Он связывает 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 #include #include #include #include #include namespace execution_core { struct StartImmediately {}; struct StartSuspended {}; template class Task { static_assert( std::is_same_v || std::is_same_v ); private: template struct base_promise { std::exception_ptr exception; Task get_return_object() noexcept { return Task{ std::coroutine_handle::from_promise( static_cast(*this) ) }; } auto initial_suspend() noexcept { if constexpr (std::is_same_v) { 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 return_void() noexcept {} }; template struct value_promise final : base_promise> { std::optional result; template void return_value(V&& value) { result.emplace(std::forward(value)); } }; public: using promise_type = std::conditional_t<<br /> std::is_void_v, void_promise, value_promise >; using handle_type = std::coroutine_handle; 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) { 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(handle_); } template requires (!std::is_void_v) U& result() & { assert(handle_); assert(handle_.done()); rethrow_if_exception(); assert(handle_.promise().result.has_value()); return *handle_.promise().result; } template requires (!std::is_void_v) 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 requires (!std::is_void_v) 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 задаёт 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 object;
- хранение coroutine handle;
- уничтожение coroutine state;
- доступ к результату для Task при T != void;
- хранение необработанного исключения через promise object.
Типы политики запуска В коде определены два пустых типа: StartImmediately и StartSuspended. Они используются как значения параметра шаблона StartPolicy. Ограничение допустимых типов задано через static_assert: StartPolicy должен быть либо StartImmediately, либо StartSuspended. Тип по умолчанию — StartSuspended. Следовательно, Task эквивалентен Task, а Task<> эквивалентен Task. Поддерживаемые формы return type coroutine-функций:
- для void-результата: Task<>, Task, Task, Task;
- для результата-значения при T != void: Task, Task, Task.
Почему разделены 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<<br /> std::is_void_v, void_promise, value_promise >;
Следствие:
Task -> void_promise Task, T != void -> value_promise
То есть для Task существует promise type с return_void(), а для Task при T != void существует promise type с return_value(...). base_promise void_promise и value_promise различаются способом обработки co_return. Общими для них остаются get_return_object(), initial_suspend(), final_suspend(), unhandled_exception() и поле exception. Поэтому общая часть вынесена в base_promise. Это устраняет дублирование одинаковых функций promise object, но сохраняет разные фактические promise types: void_promise и value_promise. base_promise использует фактический тип promise через параметр Promise, потому что std::coroutine_handle::from_promise(...) должен получить ссылку именно на фактический promise object [1][3]. В get_return_object() выполняется преобразование static_cast(*this), после чего создаётся coroutine handle через std::coroutine_handle::from_promise(...). Для from_promise стандарт задаёт постусловие addressof(h.promise()) == addressof(p), где p — promise object, из которого создан handle [1]. Последовательность создания coroutine return object Для coroutine-функции с return type Task при T != void используется Task::promise_type, то есть value_promise. Для coroutine-функции с return type Task используется Task::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.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 coroutine body после вызова coroutine-функции остаётся в начальной точке приостановки. Запуск выполняется явно через task.start(). Такой режим нужен, когда coroutine handle должен быть сначала получен, сохранён во внешней структуре управления или передан scheduler-у, и только после этого coroutine body должна начать выполнение. std::suspend_never задаёт awaitable object, у которого await_ready() возвращает true [5]. Следовательно, для Task coroutine body не останавливается в начальной точке приостановки и начинает выполнение сразу после создания return object. StartPolicy в этой реализации задаёт не runtime-флаг, а compile-time выбор результата initial_suspend(). start() Функция start() имеет действие только для Task. Для Task после подстановки 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 != void используется value_promise, внутри которого хранится std::optional result. std::optional представляет объект, который либо содержит значение типа T, либо не содержит значения [7]. До выполнения co_return value объект result не содержит значения. При выполнении co_return value вызывается promise.return_value(value). В данной реализации это приводит к вызову result.emplace(std::forward(value)). Следовательно, std::optional используется как storage для результата, который появляется не при создании coroutine state, а при выполнении co_return. Обработка void-результата Для void используется void_promise, содержащий return_void(). Для coroutine-функции с return type Task или Task<> при выполнении co_return; используется return_void() [1][2]. Результат-значение в этом случае не хранится. Доступ к результату Методы result() существуют только для Task при T != void. В реализации это задано через constraint:
template requires (!std::is_void_v)
Следовательно, для Task методы result() не участвуют в overload resolution, а для Task при T != void доступны три overload:
U& result() & const U& result() const& U&& result() &&
Они различаются ref-qualifier-ом функции-члена:
- для lvalue-объекта Task task выбирается U& result() &;
- для const lvalue-объекта const Task 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 != void сохранённое исключение повторно выбрасывается из result(). Для Task метода result() нет; проверка сохранённого исключения выполняется явным вызовом rethrow_if_exception() после завершения coroutine body. Владение coroutine handle В классе хранится handle_type handle_, где handle_type — это std::coroutine_handle. 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, но не передаёт владение 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 f();, но не задаёт поведение выражения co_await f(). Для поддержки co_await Task требуется отдельное определение awaiter protocol. Ограничение для ссылочных и неподдерживаемых типов результата Для результата используется std::optional result. std::optional содержит значение типа T как contained value [7]. Следовательно, данная реализация value-варианта определена только для таких T, для которых std::optional является well-formed и для которых выражение result.emplace(std::forward(value)) well-formed для operand конкретного co_return. Task, Task, Task и другие формы, для которых std::optional не может содержать contained value типа T, этой реализацией не поддерживаются. Сводка типов Для void-результата:
- Task<> эквивалентен Task;
- Task эквивалентен Task;
- Task использует void_promise, std::suspend_always, return_void();
- Task использует void_promise, std::suspend_never, return_void().
Для результата-значения при T != void:
- Task эквивалентен Task;
- Task использует value_promise, std::suspend_always, return_value(...);
- Task использует value_promise, std::suspend_never, return_value(...).
Итоговая схема:
Task<> -> Task Task -> Task Task -> void_promise, suspend_always, return_void() Task -> void_promise, suspend_never, return_void() Task -> Task, T != void Task -> value_promise, suspend_always, return_value(...) Task -> value_promise, suspend_never, return_value(...)
Итоговая формулировка execution_core::Task — это class template, который задаёт return object для C++20 coroutine-функций. Параметр T определяет promise type: при T == void используется void_promise, при T != void используется value_promise. Параметр StartPolicy определяет результат initial_suspend(): StartSuspended даёт std::suspend_always, а StartImmediately даёт std::suspend_never. Coroutine handle создаётся через std::coroutine_handle::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. Корректное использование этой реализации требует, чтобы операции done(), resume() и destroy() вызывались только в состояниях coroutine, для которых эти операции имеют определённое поведение по стандарту. Источники
-Источник
|
|
|
|