Корутины в C++20 под нагрузкой: где рвётся производительность
Корутины C++20 обещают дешёвую асинхронность, но под нагрузкой всплывают аллокации на каждый вызов, срыв inline-оптимизаций и гонки при возобновлении не на том потоке. Разбираем механизм и что мерить.

Асинхронный сервис на C++20-корутинах держал 5 000 запросов в секунду на четырёх ядрах, пока профилировщик не показал: 18% времени CPU уходит в operator new. Каждый вызов корутины выделял фрейм в куче — по 200-400 байт на активацию, десятки тысяч аллокаций в секунду, фрагментация арены и рост p99 latency с 4 до 30 мс под пиком. Корутины в C++ — не бесплатная абстракция. Ниже — как устроен frame, почему компилятор не всегда убирает аллокацию, и что ломается первым, когда запросов становится много.
Материал для C++20 (GCC 12+, Clang 15+, MSVC 19.3x). Корутинный ABI и правила elision между этими компиляторами различаются, где это критично — отмечено отдельно.
Что происходит на каждом co_await
Когда функция содержит co_await, co_yield или co_return, компилятор превращает её в конечный автомат. Локальные переменные, которые должны пережить точку приостановки, складываются в coroutine frame — блок памяти, который живёт между возобновлениями. Именно этот frame и есть источник основных проблем под нагрузкой.
Порядок событий при первом вызове корутины:
- Компилятор вычисляет размер frame и вызывает выделение памяти. По умолчанию это
operator new— глобальный аллокатор. - В frame копируются параметры, конструируется объект
promise_type. - Вызывается
promise.get_return_object()— возвращаемый хендл. - Исполняется тело до первой точки приостановки, после чего управление возвращается вызывающему.
На каждом co_await состояние сериализуется в frame, а при resume() — восстанавливается. Frame освобождается при завершении корутины (co_return или выход за конец тела) через operator delete.
HALO1 — оптимизация, которая должна убирать эту аллокацию, размещая frame на стеке вызывающего. Но срабатывает она только при жёстких условиях, и полагаться на неё в горячем пути без проверки нельзя.
Когда аллокация frame устраняется, а когда нет
Coroutine frame elision (heap allocation elision, HALO) работает, когда компилятор доказал, что время жизни корутины не выходит за пределы вызывающего кадра. На практике это требует, чтобы вызов и полное потребление корутины были видны в одной единице трансляции и корутина не «убегала» в хранилище.
- Elision срабатывает: маленькая корутина, заинлайненная в вызывающего, тело которого целиком видит компилятор.
- Elision срывается: хендл сохраняется в контейнер, передаётся в планировщик, возвращается наружу через
std::coroutine_handle, либо тип корутины пересекает границу единицы трансляции без LTO.
В типичном асинхронном сервере корутина именно «убегает» в планировщик — поэтому HALO там почти никогда не срабатывает, и вы платите аллокацией за каждый запрос.
Корутина, которую вы передали в scheduler, — это гарантированная аллокация в куче. HALO спасает микробенчмарки, но не архитектуру, где корутины живут в очереди задач.
Механизм: три места, где рвётся под нагрузкой
1. Аллокация frame в горячем пути
Глобальный operator new под конкуренцией тредов сериализуется на блокировках аллокатора. При десятках тысяч корутин в секунду это видно в профиле как время в malloc/free и рост фрагментации.
Лечится кастомным аллокатором на уровне promise_type: если у promise есть перегрузки operator new/operator delete, компилятор возьмёт их вместо глобальных.
#include <coroutine>
#include <cstddef>
// C++20, проверено на GCC 12 и Clang 15.
// Пул на промис — снимает нагрузку с глобального аллокатора.
struct Task {
struct promise_type {
// Кастомное выделение frame из пула вместо глобального new.
static void* operator new(std::size_t size) {
return FramePool::instance().allocate(size);
}
static void operator delete(void* ptr, std::size_t size) noexcept {
FramePool::instance().deallocate(ptr, size);
}
Task get_return_object() noexcept {
return Task{std::coroutine_handle<promise_type>::from_promise(*this)};
}
std::suspend_always initial_suspend() noexcept { return {}; }
std::suspend_always final_suspend() noexcept { return {}; }
void return_void() noexcept {}
void unhandled_exception() { std::terminate(); }
};
std::coroutine_handle<promise_type> handle;
explicit Task(std::coroutine_handle<promise_type> h) : handle(h) {}
};
Реализация FramePool здесь опущена намеренно — важен механизм подключения, а не конкретный пул. Размер frame фиксирован для данного типа корутины, поэтому пул фиксированных блоков (slab) подходит идеально.
2. Возобновление не на том потоке
Опасность корутин под нагрузкой не только в аллокациях. Когда resume() вызывается из потока пула, отличного от того, где корутина приостановилась, легко получить гонку: код после co_await исполняется в новом контексте, но обращается к данным, привязанным к прежнему потоку — thread-local, соединению с БД из пула конкретного потока, non-atomic счётчикам.
Симптом под нагрузкой: при низком RPS всё работает, при высоком — редкие повреждения данных или падения, которые не воспроизводятся в отладчике. Причина — race при переносе продолжения между потоками через awaiter, который делает symmetric transfer в чужой executor.
3. Срыв inline и раздувание frame
Компилятор кладёт в frame все переменные, живущие через точку приостановки, — включая крупные буферы, которые вы объявили внутри корутины. Один std::array<char, 8192> на стеке корутины превращается в 8 КБ в куче на каждую активацию. При тысячах одновременных корутин это гигабайты.
Правило: держите крупные объекты вне frame — передавайте по ссылке из вызывающего, берите из пула буферов, а не объявляйте локально в корутине, если они переживают co_await.
Сравнение стоимости подходов
| Подход | Аллокаций на задачу | Риск гонки | Когда брать |
|---|---|---|---|
| Корутина + глобальный new | 1 (heap) | средний | прототип, низкий RPS |
| Корутина + пул на promise | 0 после прогрева | средний | горячий путь, стабильный размер frame |
| Корутина с HALO | 0 | низкий | локальные, не убегающие в scheduler |
| Callback / state machine вручную | 0-1 | высокий (ручной) | предельная латентность, нет команды под корутины |
Ручной автомат состояний убирает frame полностью, но переносит всю сложность на вас — читаемость падает, багов в переносе состояния становится больше. Корутина с пулом — обычно лучший компромисс для сервера.
Что и как мерить
Прежде чем оптимизировать — измерьте, что вы действительно упёрлись в frame, а не в сеть или БД. Придуманные цифры бесполезны, поэтому вот что снимать на вашем железе и вашей нагрузке:
- Профиль CPU под целевым RPS: доля времени в
operator new/free. Если выше 5-10% — frame-аллокации кандидат номер один. Инструменты:perf recordна Linux, затемperf report. - Размер frame для конкретного типа корутины. Точный размер известен компилятору; для оценки смотрите, какие объекты живут через
co_await, и держите их вне тела. - Аллокации в секунду:
heaptrackили счётчики malloc покажут число и размер выделений на запрос. - p99/p999 latency до и после подключения пула — при одинаковом RPS, одинаковом железе и одинаковом объёме данных. Разница на хвосте латентности — главный признак, что аллокатор был узким местом.
Условия замера фиксируйте всегда: число ядер, размер пула потоков, RPS, размер payload. Без этого цифры «до/после» ничего не значат.
Граничные случаи, где всё ломается тише
- Исключение из тела корутины. Оно уходит в
promise.unhandled_exception(). Если тамstd::terminate— под нагрузкой одна невалидная запись роняет весь процесс. Решайте, как транслировать ошибку в результат, а не падать. - Разрушение хендла до завершения.
handle.destroy()на приостановленной корутине корректно освобождает frame, но локальные объекты с деструкторами при этом уничтожаются в состоянии, где вы могли этого не ждать. Утечки frame при забытомdestroy()— частая протечка памяти в сервисах на корутинах. - Разный ABI между компиляторами. Корутинный код, скомпилированный GCC, несовместим по frame-layout с Clang. Не смешивайте объектные файлы разных компиляторов там, где корутина пересекает границу.
- final_suspend с suspend_never. Если
final_suspendвозвращаетsuspend_never, frame уничтожается автоматически по завершении — удобно, но лишает вас возможности забрать результат из завершившейся корутины. Для задач с результатом обычно нуженsuspend_alwaysи ручнойdestroy().
Что делать дальше
Начните с профиля под реальным RPS — без него любая оптимизация frame гадание. Если operator new заметен в профиле, подключите пул через operator new/delete в promise_type и перемерьте хвост латентности. Отдельно проверьте, не убегают ли крупные буферы в frame, и вынесите их наружу. Для многопоточного пула зафиксируйте контракт: на каком executor возобновляется продолжение — это снимает самый неприятный класс гонок.
Дальше стоит разобраться с symmetric transfer (передачей управления между корутинами без роста стека) и с готовыми библиотеками — эта тема заслуживает отдельного разбора, здесь мы говорили только про стоимость frame под нагрузкой.
1 HALO (Heap Allocation eLision Optimization) — оптимизация компилятора, при которой coroutine frame размещается на стеке вызывающей функции вместо кучи, если доказано, что время жизни корутины не переживёт вызывающего.
Логотип и фирменный стиль: Три концепции разного характера, палитра с обоснованием, проверка на читаемость в малом размере и в один цвет.
Спросить за 60 ₽Источники: cppreference: Coroutines (C++20), cppreference: std::coroutine_handle
Частые вопросы
У меня Clang вместо GCC — код с пулом на promise будет работать?
Я подключил пул, но профиль всё равно показывает время в new — почему не сработало?
Насколько корутины медленнее ручного автомата состояний?
Можно ли полностью избавиться от heap-аллокаций frame?
Почему под высокой нагрузкой появляются гонки, которых не было при отладке?
Что произойдёт, если я забуду вызвать destroy() на хендле?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.