
Корутины в C++20 выглядят как магия: мы пишем почти обычную функцию с co_await и co_return, а она «сама» умеет останавливаться и продолжать выполнение. В этой части разберёмся что именно делает компилятор:
- как корутина разворачивается в структуру с полем
state; - где и как хранится кадр корутины (frame);
- как работает
co_awaitна уровне вызовов методовawait_ready/await_suspend/await_resume; - почему корутина — это не поток и не стековый switch-case, а аккуратный конечный автомат.
Мы не будем завязываться на конкретный компилятор, но опишем трансформацию так, чтобы её легко было сверить с реальным ассемблером из Compiler Explorer.
Минимальный пример корутины
Начнём с нарочито простого примера: корутина, которая сразу же возвращает число и никогда не приостанавливается.
#include <coroutine>
struct SimpleTask {
struct promise_type {
int Value = 0;
SimpleTask get_return_object() noexcept {
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_value(int V) noexcept { Value = V; }
void unhandled_exception() { std::terminate(); }
};
std::coroutine_handle<promise_type> Handle;
~SimpleTask() {
if (Handle) Handle.destroy();
}
};
SimpleTask Foo() {
co_return 42;
}
Важно:
- наличие
co_returnделаетFooкорутинной функцией; - возвращаемый тип
SimpleTaskобязан содержать вложенныйpromise_type; get_return_object()возвращает объект, который увидит вызывающая сторона (SimpleTask);initial_suspend/final_suspendрешают, будет ли корутина останавливаться в начале/конце.
С точки зрения компилятора Foo не превращается в обычную функцию, которая просто возвращает 42. Вместо этого создаётся машина состояний.
Фрейм корутины и coroutine_handle
Каждая корутина в C++20 имеет собственный кадр (frame) — участок памяти, где лежат:
- захваченные аргументы и локальные переменные, которые должны пережить
co_await; - служебные поля: индекс текущего состояния, указатели на promise и vtable’ы;
- технические флаги (завершилась ли корутина, есть ли невыловленное исключение и т.п.).
Логически фрейм можно представить так:
struct Foo_frame {
// служебное
void (*ResumeFn)(Foo_frame*); // куда прыгать при resume()
int State; // текущий "case" автомата
// promise
SimpleTask::promise_type Promise;
// захваченные аргументы и локальные
// (в нашем Foo их нет)
};
std::coroutine_handle<promise_type> по сути содержит указатель на этот фрейм:
struct coroutine_handle_base {
Foo_frame* Frame;
void resume() { ResumeFn(Frame); }
void destroy() { /* вызвать финализацию и освободить память */ }
};
На уровне ассемблера resume() — это просто косвенный прыжок через указатель:
; RDI = this (coroutine_handle)
; [RDI] = Frame*
; [RDI+8] = ResumeFn
mov rax, [rdi+8] ; загрузить ResumeFn
mov rcx, [rdi] ; rcx = Frame*
jmp rax ; перейти в функцию, зная адрес кадра
Где именно лежит Foo_frame — зависит от реализации:
- чаще всего это динамическая аллокация через специальный оператор
operator newдля корутин; - иногда компилятор может оптимизировать и разместить фрейм на стеке вызывающего кода (elision), если видит, что корутина не утекает наружу.
Разворачивание в конечный автомат
Посмотрим теперь на более интересный пример, где есть хотя бы одна точка приостановки.
struct Logger {
struct promise_type {
Logger get_return_object() {
return Logger{ 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;
};
Logger LogTwice() {
puts(\"step 1\");
co_await std::suspend_always{};
puts(\"step 2\");
}
Логика на уровне исходного кода:
- Напечатать
step 1. - Приостановиться.
- При следующем
resume()напечататьstep 2и завершиться.
Компилятор разворачивает это примерно во что‑то вроде:
struct LogTwice_frame {
void (*ResumeFn)(LogTwice_frame*);
int State;
Logger::promise_type Promise;
};
void LogTwice_resume(LogTwice_frame* F) {
switch (F->State) {
case 0: goto State0;
case 1: goto State1;
}
State0:
puts(\"step 1\");
// co_await std::suspend_always{};
F->State = 1;
// suspend_always::await_ready() всегда false,
// поэтому корутина приостанавливается:
return;
State1:
puts(\"step 2\");
// завершаемся — управление пойдёт в final_suspend()
F->State = -1;
return;
}
Ключевые моменты:
Stateкодирует где именно мы находимся внутри корутины;- при первом вызове
resume()Stateравен 0 → выполняется код до первогоco_await; - перед приостановкой мы запоминаем следующее состояние (
State = 1) и выходим изresume(); - при следующем
resume()switchперенаправляет выполнение вState1.
В ассемблере это выглядит как обычный код с cmp / jmp и несколькими блоками:
; В начале LogTwice_resume:
mov eax, [rcx + STATE_OFFSET] ; rcx = Frame*
cmp eax, 1
je .Lstate1
; по умолчанию — состояние 0
.Lstate0:
; puts(\"step 1\");
; ...
mov dword ptr [rcx + STATE_OFFSET], 1
ret
.Lstate1:
; puts(\"step 2\");
; ...
mov dword ptr [rcx + STATE_OFFSET], -1
ret
Никаких скрытых потоков, переключения стеков или волшебства — просто одна структура и обычные прыжки по меткам.
Как работает co_await
До сих пор мы использовали только std::suspend_always, который:
struct suspend_always {
bool await_ready() const noexcept { return false; }
void await_suspend(std::coroutine_handle<>) const noexcept {}
void await_resume() const noexcept {}
};
Компилятор разворачивает выражение co_await expr в последовательность шагов (упрощённо):
auto&& Awaitable = expr;
auto Awaiter = get_awaiter(Awaitable); // operator co_await или сам объект
if (!Awaiter.await_ready()) {
// приостанавливаемся
F->State = NextState;
Awaiter.await_suspend(std::coroutine_handle<promise_type>::from_promise(F->Promise));
return;
}
// при возобновлении:
auto result = Awaiter.await_resume();
Где get_awaiter — это поиск одного из:
expr.operator co_await();operator co_await(expr);- сам
expr, если в нём уже естьawait_ready/await_suspend/await_resume.
Псевдо‑ASM для co_await
Представим, что Awaiter лежит в локале фрейма по смещению AWT_OFFSET. На уровне ассемблера ключевые куски будут выглядеть так:
; вызов await_ready()
lea rdx, [rcx + AWT_OFFSET] ; rdx = &Awaiter
mov rax, [AWAIT_READY_PTR] ; адрес функции await_ready
call rax
test al, al
jne .Lready ; если true — не приостанавливаемся
; НЕ готово: нужно приостановиться
mov dword ptr [rcx + STATE_OFFSET], NEXT_STATE
; сформировать coroutine_handle и вызвать await_suspend()
; условно: r8 = handle(F)
mov r8, rcx
lea rdx, [rcx + AWT_OFFSET] ; rdx = &Awaiter
mov rax, [AWAIT_SUSPEND_PTR]
call rax
; дальше в зависимости от возвращаемого типа await_suspend
ret ; выходим из resume()
.Lready:
; вызов await_resume()
lea rdx, [rcx + AWT_OFFSET]
mov rax, [AWAIT_RESUME_PTR]
call rax
; результат в RAX (если есть)
Реальный код будет чуть сложнее (учёт возвращаемого значения await_suspend, оптимизации и т.п.), но концепция остаётся такой же: три вызова + управление State.
initial_suspend и final_suspend
В каждом promise_type компилятор вызывает:
co_await prom.initial_suspend()перед выполнением тела корутины;co_await prom.final_suspend()после завершения (последнегоco_returnили выброса исключения).
Именно эти точки определяют «жизненный цикл»:
std::suspend_never→ корутина сразу начинает выполнение (без паузы на старте);std::suspend_always→ корутина создаётся в остановленном состоянии, и вызывающая сторона должна явно вызватьhandle.resume();- в
final_suspendпочти всегда используютstd::suspend_always, чтобы внешний код мог безопасно забрать результат и сам решить, когда уничтожать корутину.
Пример типичного promise_type для Task:
struct promise_type {
std::suspend_always initial_suspend() noexcept { return {}; }
std::suspend_always final_suspend() noexcept { return {}; }
// ...
};
Здесь:
- при создании Task корутина сразу переходит в состояние
SUSPENDEDи ждёт первогоresume(); - после завершения (
co_return) корутина останавливается в состоянииFINAL_SUSPEND, и только после вызоваdestroy()кадр будет освобождён.
Исключения и unhandled_exception
Если в теле корутины выбрасывается исключение, которое не перехватывается внутри, компилятор вместо обычного раскрутки стека вызывает метод:
void promise_type::unhandled_exception();
Дальнейшее поведение зависит от реализации:
- Task может сохранить
std::exception_ptrвнутриpromise_typeи пробросить его приawait_resume; - генератор может завершить последовательность и при попытке продолжения бросить исключение;
- или, как в наших простых примерах, вызвать
std::terminate().
Итог
Главное понимание после первой части:
- корутина — это объект с кадром и состоянием, а не «особая функция»;
std::coroutine_handle— всего лишь указатель на фрейм и пара служебных вызовов;co_awaitвсегда разворачивается в три шага:await_ready,await_suspend,await_resume;- всё это реализуется компилятором как обычный конечный автомат с
switch/jmp, который легко увидеть в ассемблере.
В следующей части мы напишем свой task<T>, разберём подробно контракт promise_type и awaiter, а также посмотрим на более сложные схемы приостановки и возобновления, в том числе с несколькими встроенными co_await подряд.