Корутины в C++20: что компилятор делает за кулисами
co_await не блокирует поток и не создаёт нового — компилятор разбивает функцию на состояния и складывает их в кучу. Разбираем, что генерируется, когда происходит выделение памяти и где это бьёт по производительности.

Вы написали функцию с co_await, она компилируется, работает — и на этом понимание заканчивается. А потом профайлер показывает, что на каждый вызов корутины прилетает operator new, и под нагрузкой в 200 тысяч задач это становится заметно. Чтобы понять, откуда взялось выделение памяти и можно ли его убрать, надо знать, во что компилятор превращает тело корутины. Разбираем на GCC 13/Clang 17 с флагом -std=c++20.
Что делает наличие co_await, co_yield или co_return
Любая функция, в теле которой встречается хотя бы одно из ключевых слов co_await, co_yield или co_return, перестаёт быть обычной функцией. Компилятор превращает её в корутину: разбивает тело на части по точкам приостановки и собирает конечный автомат. Возвращаемый тип функции при этом должен удовлетворять требованиям к типу корутины — компилятор ищет в нём вложенный тип promise_type.
Важная деталь, на которой спотыкаются: сигнатура функции внешне выглядит как обычная, но её нельзя пометить constexpr, у неё не может быть переменного числа аргументов в стиле C, и она не может быть main. Определить корутину по объявлению нельзя — только по телу.
Frame: где живёт состояние приостановленной корутины
Когда обычная функция приостанавливается — она этого не делает, она либо выполняется, либо вернула управление. Корутина же должна пережить приостановку: её локальные переменные, аргументы и точка возобновления обязаны где-то храниться между co_await и последующим возобновлением. Это место — coroutine frame.
Стек для этого не годится: кадр стека уничтожается при возврате из функции, а корутина возвращает управление вызывающему коду, оставаясь живой. Поэтому по умолчанию компилятор вызывает operator new и размещает фрейм в куче. Именно это выделение и видит профайлер.
Во фрейм упаковываются:
- объект
promise_type— через него корутина общается с внешним миром; - копии или ссылки на аргументы функции (значения копируются во фрейм, ссылки остаются ссылками — и это источник висячих ссылок, о нём ниже);
- локальные переменные, живущие через точку приостановки;
- служебное состояние: индекс текущего шага автомата и указатель на функцию возобновления.
Корутина в C++20 — это не про потоки и не про параллелизм. Это механизм, который позволяет функции приостановиться и вернуть управление, сохранив своё состояние. Что делать с приостановленной корутиной — возобновить в том же потоке, в другом или никогда — решаете вы через тип awaitable.
Во что разворачивается тело: promise, awaiter, три перехода
Компилятор оборачивает тело корутины примерно в такую схему (запись схематичная, реальный код сложнее и зависит от компилятора):
// Схематично: что генерируется для корутины возвращающей ReturnType
{
// 1. Выделяется frame, в нём конструируется promise
__frame* f = new __frame;
ReturnType ret = f->promise.get_return_object();
// 2. Начальная точка приостановки
co_await f->promise.initial_suspend();
try {
// ... здесь ваше тело, разрезанное по co_await ...
} catch (...) {
f->promise.unhandled_exception();
}
// 3. Конечная точка приостановки
co_await f->promise.final_suspend();
}
Ключевые точки — initial_suspend() и final_suspend(). Если ваш promise_type возвращает из initial_suspend() объект std::suspend_always, корутина приостановится сразу при вызове, ещё не выполнив ни строчки тела. Если std::suspend_never — начнёт выполняться немедленно. Это первое, что стоит проверять, когда «корутина не запускается» или наоборот «запустилась слишком рано».
Что происходит на co_await expr
Выражение co_await expr компилятор превращает в последовательность вызовов методов awaiter-объекта:
await_ready()— если вернётtrue, приостановки не будет, значение готово синхронно;await_suspend(handle)— сюда передаётся хендл текущей корутины; здесь решается, кому передать управление и когда возобновить эту корутину;await_resume()— вызывается при возобновлении, его результат становится значением всего выраженияco_await.
await_suspend может вернуть void (управление уходит вызывающему), bool (false — не приостанавливаться) или другой coroutine_handle — это symmetric transfer, передача управления другой корутине без роста стека. Последний вариант критичен для цепочек корутин: без него глубокая вложенность co_await переполняет стек.
Замер: сколько стоит выделение фрейма
Точные цифры зависят от компилятора, флагов оптимизации и размера фрейма, поэтому вместо выдуманных чисел — что и как мерить у себя. Соберите микробенчмарк, который в цикле создаёт и прогоняет простую корутину, и сравните с эквивалентным кодом без корутин. Смотрите на два показателя:
- число вызовов
operator new— через перегрузку глобальногоnewсо счётчиком или через профайлер аллокаций (heaptrack, valgrind --tool=massif); - время на итерацию под
-O2и под-O0— разница покажет, сработала ли оптимизация.
Компилятор имеет право элиминировать выделение фрейма (Heap Allocation eLision Optimization, HALO), если время жизни корутины полностью вложено в вызывающий код и не пересекает границу функции. На практике HALO срабатывает редко и капризно: достаточно передать хендл наружу или обернуть корутину в тип с нетривиальной семантикой владения — и выделение остаётся. Не рассчитывайте на HALO, проверяйте по факту.
Сравнение стратегий работы с фреймом
| Подход | Выделение памяти | Когда применять |
|---|---|---|
Дефолтный operator new | Куча, на каждый вызов | Низкая частота создания корутин, простота важнее скорости |
Кастомный operator new в promise_type | Пул или арена | Много короткоживущих корутин, узкое место в аллокаторе |
| Расчёт на HALO | Возможно ноль | Только после проверки, что элизия реально сработала |
Кастомный аллокатор задаётся так: объявите static void* operator new(std::size_t) и парный operator delete внутри вашего promise_type. Компилятор будет использовать именно их для фрейма этой корутины. Это способ посадить все фреймы одного типа корутин на пул фиксированного размера.
Где это ломается
Три граничных случая, которые дают самые тяжёлые для отладки баги.
Висячие ссылки на аргументы
Аргументы-ссылки и указатели не копируются во фрейм — во фрейм попадает сама ссылка. Если аргумент был временным объектом, к моменту первого возобновления он уже уничтожен. Классика:
// ОПАСНО: параметр по ссылке на временный объект
Task process(const std::string& data) {
co_await something();
// data уже может указывать на разрушенный объект,
// если корутину вызвали как process("literal")
use(data);
}
Лечение: принимайте потенциально временные аргументы по значению — тогда они копируются во фрейм и живут вместе с ним. Компилятор об этой ловушке не предупреждает.
Двойное возобновление и возобновление завершённой корутины
Вызов resume() на корутине, которая уже дошла до final_suspend и завершилась, — неопределённое поведение. Проверяйте handle.done() перед возобновлением, если не уверены в состоянии.
Утечка фрейма
Фрейм освобождается либо при завершении корутины через final_suspend, либо явным handle.destroy(). Если ваш final_suspend возвращает suspend_always (частый паттерн, чтобы прочитать результат после завершения), кто-то обязан вызвать destroy() вручную. Забыли — фрейм течёт. Обычно это ответственность деструктора обёртки-Task через RAII.
Что делать дальше
Если пишете свой тип корутины, не изобретайте promise_type с нуля — возьмите готовые примитивы из библиотеки, где это уже отлажено: cppcoro (учебная, но проверенная временем) или std::execution-совместимые библиотеки. Для продакшена под нагрузкой первым делом снимите профиль аллокаций и решите, нужен ли кастомный operator new. И держите под рукой ассемблерный вывод через godbolt.org с флагом -fcoroutines — увидеть сгенерированный автомат полезнее любого текста про корутины.
Источники: cppreference: Coroutines (C++20), C++ Draft: [dcl.fct.def.coroutine]
Частые вопросы
У меня Clang, а не GCC — код из статьи будет работать?
Корутина не запускается — тело не выполняется. В чём дело?
Как убрать выделение памяти на каждый вызов корутины?
Корутины в C++20 — это про многопоточность?
Почему падает при обращении к аргументу после co_await?
Что такое symmetric transfer и зачем он нужен?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.