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

  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 != 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.

Источники

  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