Корутины в C++: часть 1 — что делает компилятор

Диаграмма автомата корутины

Корутины в 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\");
}

Логика на уровне исходного кода:

  1. Напечатать step 1.
  2. Приостановиться.
  3. При следующем 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 — это поиск одного из:

  1. expr.operator co_await();
  2. operator co_await(expr);
  3. сам 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 подряд.