Корутины в C++: часть 2 — awaiter, awaitable и свой task<T>

В первой части мы разобрали, как компилятор разворачивает корутину в конечный автомат с State и фреймом. Теперь пойдём глубже и разберём контракт co_await:

  • чем awaitable отличается от awaiter;
  • как компилятор находит await_ready / await_suspend / await_resume;
  • как написать свой task<T> и co_await на нём;
  • что происходит при цепочках co_await и как это выглядит в памяти.

Awaitable и awaiter: формальный контракт

Выражение:

co_await expr;

компилятор разворачивает в последовательность шагов:

  1. Получить awaitable:
    • если у типа есть operator co_await, он вызывается;
    • иначе ищется свободная функция operator co_await(expr);
    • если ни того ни другого нет, сам expr считается awaitable.
  2. Полученный объект называется awaiter. Он должен иметь методы:
    • bool await_ready();
    • void await_suspend(std::coroutine_handle<>);
    • T await_resume();
  3. Дальше идёт та самая тройка вызовов, которую мы уже видели:
    • если await_ready() == true → не приостанавливаемся, сразу await_resume();
    • иначе вызываем await_suspend(handle) и выходим из resume();
    • при возобновлении вызываем await_resume() и получаем результат.

Разделение зачастую такое:

  • awaitable — объект «что подождать» (запрос в БД, таймер, сетевой read);
  • awaiter — объект «как ждать» (логику приостановки/пробуждения корутины).

На практике для простых типов это один и тот же объект.

Минимальный awaiter своими руками

Начнём с игрушечного awaiter’а, который просто откладывает продолжение корутины в некий очередь‑планировщик:

struct SimpleScheduler;

struct SimpleAwaiter {
  SimpleScheduler& Sched;

  bool await_ready() const noexcept {
    return false; // всегда приостанавливаемся
  }

  void await_suspend(std::coroutine_handle<> H) const noexcept;

  void await_resume() const noexcept {
    // ничего не возвращаем
  }
};

struct SimpleScheduler {
  std::vector<std::coroutine_handle<>> Queue;

  void Enqueue(std::coroutine_handle<> H) {
    Queue.push_back(H);
  }

  void Run() {
    while (!Queue.empty()) {
      auto H = Queue.back();
      Queue.pop_back();
      H.resume();
    }
  }
};

inline void SimpleAwaiter::await_suspend(std::coroutine_handle<> H) const noexcept {
  Sched.Enqueue(H);
}

Использование в корутине:

SimpleScheduler GlobalSched;

struct SimpleTask {
  struct promise_type {
    SimpleTask get_return_object() {
      return SimpleTask{
        std::coroutine_handle<promise_type>::from_promise(*this)
      };
    }
    std::suspend_never initial_suspend() noexcept { return {}; }
    std::suspend_never final_suspend()   noexcept { return {}; }
    void return_void() noexcept {}
    void unhandled_exception() { std::terminate(); }
  };

  std::coroutine_handle<promise_type> Handle;

  ~SimpleTask() {
    if (Handle) Handle.destroy();
  }
};

SimpleAwaiter Schedule() {
  return SimpleAwaiter{GlobalSched};
}

SimpleTask Example() {
  puts("before");
  co_await Schedule();
  puts("after");
}

Сценарий:

int main() {
  Example();           // корутина стартует, печатает "before" и попадает в очередь
  GlobalSched.Run();   // выполняет отложенные корутины, печатаем "after"
}

На уровне автомата всё выглядит так:

  • при первом resume() корутина доходит до co_await Schedule() и вызывает await_suspend, который просто кладёт handle в Queue;
  • resume() выходит → корутина считается приостановленной;
  • при вызове Run() мы берём handle из очереди и снова вызываем resume(), попадая в следующий case и печатая "after".

Собственный task

Теперь напишем более полноценный task<T>, у которого можно:

  • co_return value; внутри корутины;
  • co_await task<T> снаружи, чтобы дождаться результата.

Начнём с объявления:

template <typename T>
struct task;

template <typename T>
struct task_promise {
  T Value;
  std::exception_ptr Error;

  task<T> get_return_object() noexcept;
  std::suspend_always initial_suspend() noexcept { return {}; }
  std::suspend_always final_suspend()   noexcept { return {}; }

  void return_value(T V) noexcept {
    Value = std::move(V);
  }

  void unhandled_exception() noexcept {
    Error = std::current_exception();
  }
};

template <typename T>
struct task {
  using promise_type = task_promise<T>;
  using handle_type  = std::coroutine_handle<promise_type>;

  handle_type Handle;

  explicit task(handle_type H) : Handle(H) {}
  task(task&& Other) noexcept : Handle(Other.Handle) {
    Other.Handle = nullptr;
  }

  ~task() {
    if (Handle) Handle.destroy();
  }
};

template <typename T>
task<T> task_promise<T>::get_return_object() noexcept {
  return task<T>{ std::coroutine_handle<task_promise>::from_promise(*this) };
}

Awaiter для task

Чтобы task<T> можно было co_await‑ить, определим awaiter:

template <typename T>
struct task_awaiter {
  using handle_type = typename task<T>::handle_type;

  handle_type Handle;

  bool await_ready() const noexcept {
    // простейший вариант: считаем, что корутина всегда приостанавливается
    return false;
  }

  void await_suspend(std::coroutine_handle<> Caller) const {
    // В реальной жизни здесь мы бы связали завершение task с пробуждением Caller.
    // В упрощённом виде просто кладём Caller в очередь, а Handle стартуем:
    GlobalSched.Enqueue(Caller);
    Handle.resume();
  }

  T await_resume() const {
    if (Handle.promise().Error) {
      std::rethrow_exception(Handle.promise().Error);
    }
    return Handle.promise().Value;
  }
};

template <typename T>
task_awaiter<T> operator co_await(task<T>& Tsk) noexcept {
  return task_awaiter<T>{ Tsk.Handle };
}

В реальном приложении логика await_suspend чаще:

  • либо подписывает вызывающую корутину на завершение внутреннего task;
  • либо делает chaining: при завершении одного task продолжить другой.

Главное, что нужно запомнить:

  • await_suspend получает handle вызывающей корутины;
  • именно здесь мы решаем, когда она будет возобновлена.

Цепочки co_await

Рассмотрим пример:

task<int> Foo();
task<int> Bar();

task<int> Baz() {
  int A = co_await Foo();
  int B = co_await Bar();
  co_return A + B;
}

Фрейм Baz будет содержать:

  • State (0 — до первого co_await, 1 — между Foo и Bar, 2 — после Bar);
  • локальные A и B; -, возможно, временный awaiter.

Упрощённый автомат:

void Baz_resume(Baz_frame* F) {
  switch (F->State) {
    case 0: goto S0;
    case 1: goto S1;
  }

S0:
  // A = co_await Foo();
  F->State = 1;
  // запустить Foo, подписаться на его завершение,
  // приостановиться и вернуться
  return;

S1:
  // здесь Foo уже завершился, результат лежит в его promise
  F->A = FooResult(F);

  // B = co_await Bar();
  F->State = 2;
  // аналогично запускаем Bar и приостанавливаемся
  return;

S2:
  F->B = BarResult(F);
  auto Sum = F->A + F->B;
  F->Promise.return_value(Sum);
  F->State = -1;
  return;
}

На уровне ассемблера между состояниями будут обычные блоки с cmp / je / jmp, как и в примере из первой части. Но важно понимать, что каждый co_await — это отдельный шаг автомата, который может отложить продолжение на произвольное время.

Lifetime и утечки

Поскольку фрейм корутины обычно живёт в динамической памяти, легко сделать утечку:

task<int> Foo() {
  co_await Schedule(); // кладём handle куда-то
  co_return 42;
}

void Bad() {
  auto T = Foo();
  // T уничтожен, но где-то остался handle в очереди планировщика
}

Если планировщик позже вызовет resume() по висящему handle, поведение неопределено: фрейм уже уничтожен. Поэтому в реальных runtime:

  • либо task владеет фреймом и гарантирует, что пока handle жив — фрейм не будет уничтожен;
  • либо используется shared‑владение (shared_ptr к внутреннему состоянию);
  • либо чётко определена политика: кто именно отвечает за destroy().

Краткий конспект части 2

  • awaitable — объект, который можно co_await‑ить;
    awaiter — объект, реализующий тройку методов await_*.
  • Компилятор разворачивает co_await expr в: получение awaiter’а → await_ready → при необходимости await_suspend(handle) → при возобновлении await_resume.
  • Свой task<T> строится вокруг promise_type, который хранит Value и Error и управляет жизненным циклом фрейма.
  • Awaiter для task<T> решает, когда будет возобновлена вызывающая корутина, и как ей отдать результат или исключение.
  • Неаккуратная работа с destroy()/утечками handle легко приводит к UB — корутины требуют дисциплины в управлении временем жизни.

В следующей, третьей части посмотрим на практические паттерны: асинхронный I/O, игровые сценарии и архитектурные подходы, где корутины делают код реальных систем проще, а где лучше остаться на явных state machine или акторах.