В первой части мы разобрали, как компилятор разворачивает корутину в конечный автомат с State и фреймом. Теперь пойдём глубже и разберём контракт co_await:
- чем awaitable отличается от awaiter;
- как компилятор находит
await_ready / await_suspend / await_resume; - как написать свой
task<T>иco_awaitна нём; - что происходит при цепочках
co_awaitи как это выглядит в памяти.
Awaitable и awaiter: формальный контракт
Выражение:
co_await expr;
компилятор разворачивает в последовательность шагов:
- Получить awaitable:
- если у типа есть
operator co_await, он вызывается; - иначе ищется свободная функция
operator co_await(expr); - если ни того ни другого нет, сам
exprсчитается awaitable.
- если у типа есть
- Полученный объект называется awaiter. Он должен иметь методы:
bool await_ready();void await_suspend(std::coroutine_handle<>);T await_resume();
- Дальше идёт та самая тройка вызовов, которую мы уже видели:
- если
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 или акторах.