Универсальный Task для C++20 coroutines: тип результата, политика запуска и владение coroutine state

от автора

В статье рассматривается заголовочный компонент 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:

  1. вызывается coroutine-функция;

  2. создаётся coroutine state;

  3. в coroutine state создаётся promise object;

  4. вызывается promise.get_return_object();

  5. get_return_object() создаёт Task;

  6. Task получает std::coroutine_handle<promise_type>;

  7. вызывается promise.initial_suspend();

  8. дальнейшее поведение зависит от результата 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, для которых эти операции имеют определённое поведение по стандарту.

Источники

  1. ISO/IEC 14882:2020(E), разделы:

    • Coroutine definitions;

    • Coroutine promise;

    • Coroutine handle;

    • Coroutine state lifetime.

  2. cppreference: Coroutines https://en.cppreference.com/w/cpp/language/coroutines

  3. cppreference: std::coroutine_handle https://en.cppreference.com/w/cpp/coroutine/coroutine_handle

  4. cppreference: std::suspend_always https://en.cppreference.com/w/cpp/coroutine/suspend_always

  5. cppreference: std::suspend_never https://en.cppreference.com/w/cpp/coroutine/suspend_never

  6. cppreference: std::conditional https://en.cppreference.com/w/cpp/types/conditional

  7. cppreference: std::optional https://en.cppreference.com/w/cpp/utility/optional

  8. cppreference: std::exception_ptr https://en.cppreference.com/w/cpp/error/exception_ptr

ссылка на оригинал статьи https://habr.com/ru/articles/1031644/