Сетевой сервис, где шаги операции связаны обратными вызовами, через три года поддержки превращается в лабиринт: чтобы понять, откуда прилетает ответ, приходится листать пять файлов и держать в голове порядок из семи лямбд. Корутины C++20 обещают вывести код из лабиринта: те же шаги записываются линейно, одно действие за другим, а паузы между ними берёт на себя компилятор. Обещание честное, но с оговорками, о которых молчат восторженные обзоры. Стандарт дал механизм без готовой библиотеки, механика под капотом не выглядит как обычные вызовы, а отладка требует новых привычек. Ниже разбор того, что корутины дают асинхронному коду сегодня, насколько созрела экосистема вокруг них и где кроется плата за линейный стиль. Картину стоит пересобрать заново: с момента публикации C++20 в декабре 2020 года рынок прошёл путь от самодельных велосипедов к устоявшимся каркасам, компиляторы отработали острые углы, а часть старых предостережений устарела. Правда, не все: главная плата за линейный стиль осталась там же, под капотом.
Что корутины C++20 дают асинхронному коду и чего не дают
В язык вошли три ключевых слова: co_await приостанавливает выполнение до готовности операции, co_yield отдаёт значение наружу, co_return завершает корутину. Функция, содержащая любое из них, компилятор превращает в объект, который можно останавливать и возобновлять. Сетевое чтение перестаёт быть лестницей обратных вызовов:
task<std::string> read_config(connection& c) {
auto bytes = co_await c.read(1024);
co_return std::string{bytes.begin(), bytes.end()};
}
Три строчки читаются как последовательность действий, хотя каждая строка прячет приостановку, передачу управления другим задачам и возвращение позже. Именно за это корутины и полюбили: асинхронный код перестаёт отличаться от синхронного по форме.
Терминология, которая понадобится дальше. Корутины C++20 не имеют собственного стека: между приостановками сохраняется только кадр с локальными переменными, а не вся глубина вызовов. Это делает их дешёвыми по памяти, в отличие от потоков с полным стеком, и одновременно диктует ограничение: приостанавливаться умеет только сама корутина, обычная функция, вызванная из неё, приостановить выполнение уже не может.
Первая и главная оговорка: стандарт C++20 дал механизм и не дал библиотеки. Заголовок <coroutine> содержит каркас типов для построения своих корутин, но готового task или генератора в стандарте 2020 года нет. Автору кода предлагается либо взять чужую библиотеку, либо собрать собственный велосипед с promise_type, что для новичка превращается в недельное приключение. В языках, где асинхронность приходила с готовой библиотекой, такой пропасти между языком и практикой не возникало, и именно она определила первые годы экосистемы C++.
Как устроена механика корутины от promise_type до кадров на куче
Компилятор превращает корутину в машину состояний. Локальные переменные переезжают в отдельный кадр, который создаётся в куче и живёт между приостановками, а код разбивается на сегменты по точкам co_await. За поведение кадра отвечает тип с обязательным набором методов, promise_type:
struct task_promise {
task<std::string> get_return_object();
std::suspend_always initial_suspend() noexcept;
std::suspend_always final_suspend() noexcept;
void return_value(std::string v);
void unhandled_exception();
};
За поведение ожидания отвечает awaiter с тремя методами: await_ready проверяет, нужно ли ждать вообще, await_suspend решает, что делать с текущим кадром, await_resume возвращает результат в точку ожидания:
struct socket_awaiter {
bool await_ready() noexcept { return false; }
void await_suspend(std::coroutine_handle<> h) noexcept;
int await_resume() noexcept;
};
Два практических следствия этой механики. Первое: кадр корутины обычно выделяется в куче, стандарт разрешает компилятору экономить и размещать кадр на стеке, но не обязывает, поэтому на платформах с жёсткими ограничениями памяти нельзя закладывать оптимизацию в проект. Второе: возобновление корутины устроено как прямой переключение на её кадр, и в зрелых реализациях применяется симметричная передача, при которой корутины передают управление друг другу, не разматывая стек вызывающих. Для прикладного кода это внутренняя механика, но именно от качества этой части зависит, выдержит ли сервер миллион активных ожиданий или утонет в стеке.
Отдельное предупреждение для оптимистов: неидеальности promise_type проявляются не при компиляции, а в рантайме. Один неверный noexcept, одно исключение мимо unhandled_exception, и программа падает там, где обычный код просто выбросил бы понятную ошибку.
Отдельная ловушка, время жизни объектов. Кадр корутины держит ссылки и умные указатели между приостановками, но если вызывающий код уничтожит ресурс до возобновления, корутина продолжит работу с висячей ссылкой. В обычных функциях время жизни ограничено кадром стека, в корутинах границы размазаны по ожиданиям, поэтому ссылки в кадр передают с удвоенной осторожностью.
Зрелость экосистемы от cppcoro до Boost.Asio и каркаса folly
За годы после выхода стандарта экосистема выстроила несколько опорных точек. cppcoro Льюиса Бейкера, эталонная библиотека примитивов, научила сообщество, как корутины должны выглядеть, хотя темп её развития давно замедлился. Boost.Asio встроил корутины в самый массовый сетевой каркас: комбинаторы use_awaitable и co_spawn позволяют переписать обработчик соединения в линейный стиль, не покидая знакомый стек. Каркас folly из большой социальной компании принёс производственные масштабы и суровую практику, его coroutine-слой выдерживает нагрузки, о которых учебные примеры не подозревают.
Отдельная ветка, senders и receivers из libunifex, стала полигоном для будущей стандартной механики и постепенно переросла в предложение std::execution. Практический вывод прост: выбор библиотеки корутин, это выбор экосистемы. Кто работает с Boost.Asio, берёт его же корутины, кто в большом сервисе, смотрит на тяжёлые каркасы, а самописные task-типы остаются уделом встроенных систем с жёсткими ограничениями на зависимости.
Ещё один признак зрелости каркаса, отмена операций. У бизнес-операций есть срок жизни, и взрослые библиотеки дают токены отмены или остановку через awaiter. В самодельных реализациях отмена обычно забыта до первого инцидента, когда повисшее ожидание держит соединение часами и ест лимиты.
Стандартная библиотека догоняет через генератор C++23 и каркас execution
Пропасть между механизмом и практикой закрывается поэтапно. В C++23 стандарт получил std::generator, ленивую последовательность значений, первый готовый корутинный тип в библиотеке. Это закрыло самый частый учебный случай и дало сообществу эталонную реализацию, на которой можно изучать механику без сторонних зависимостей.
Следующий шаг, std::execution, общий каркас асинхронных операций с едиными правилами составления, отмены и обработки ошибок, утверждён в составе C++26. Для прикладных разработчиков это означает, что асинхронная композиция перестаёт быть племенной: библиотеки сетевого ввода-вывода, планировщики и пулы получают общий словарь взаимодействия, а корутины становятся одним из способов потребления этого словаря.
До практического развёртывания всего этого в продакшене, естественно, годы: целевые платформы должны добраться до C++23 и C++26, а затем библиотеки должны принять новые каркасы. На сегодня рабочая связка выглядит так: язык в режиме C++20, готовый task из зрелой библиотеки и осторожное отношение к самоделкам. Генератор из C++23 закрывает и прикладные задачи: потоковые разборы файлов и логов на нём пишутся компактно, без ручных итераторов.
Отладка корутин и почему трассировки стека обманывают
Самая недооценённая часть корутин не в написании, а в разборе полётов. Обычная трассировка стека в момент ошибки показывает, кто возобновил корутину, а не логическую цепочку, которая к ней привела: кадры живут в куче по отдельности, а стек вызывающих обрывается на последнем переключении. Ошибка внутри awaiter уводит следователя далеко от причины, и без подготовки расследование превращается в поиск вслепую.
Три привычки, которые сильно снижают цену отладки. Первая, логи на границах: каждое пересечение co_await в первое время снабжается трассировочными записями, иначе между приостановкой и возобновлением теряется вся видимость. Вторая, именование: у кадров корутин появляются осмысленные имена, чтобы профилировщик и снимки памяти отличали ожидание сокета от ожидания базы. Третья, изоляция: собственные awaiter-ы тестируются как отдельные единицы с прямым управлением, без запуска всей асинхронной магистрали.
Отладчики постепенно учатся показывать кадры приостановленных корутин, но ставить на это стратегию рано: разброс версий и платформ в реальных проектах велик, а привычки отладки не зависят от инструмента. Команда, которая пишет корутины без дисциплины логирования, платит за линейный стиль круглыми ночами.
Профилировщики страдают той же болезнью: время между приостановкой и возобновлением не привязано ни к одному кадру стека и оседает в отчётах анонимными блоками. Осмысленные имена кадров и маркеры на границах ожиданий возвращают отчётам читаемость.
Практические приёмы внедрения корутин в существующий код
Внедрение окупается, когда движется от самой болезненной точки и с готовой библиотекой под рукой. Проверенный порядок выглядит так:
- Начать с магистрали ввода-вывода, где каскады обратных вызовов уже мешают поддержке и где выигрыш в читаемости виден всем;
- Взять готовый тип task из зрелой библиотеки проекта, не пишите собственный promise_type в первый месяц работы;
- Держать переходный синхронный вариант за общим интерфейсом, чтобы откат не стоил переписывания вызовов;
- Прогонять корутины через те же тесты и метрики, что и остальной код, без поблажек за сложность механики;
- Логировать каждое пересечение границы co_await минимум первые полгода жизни кода;
- Ограничить самодельные awaiter-ы одним модулем с обязательным ревью и документацией каждого перехода.
Ничего экзотического в списке нет, но именно он отличает проекты, где корутины прижились, от проектов, где их выкорчевали через год. Типичный срок внедрения на живом проекте, квартал: первый месяц уходит на одну магистраль, второй на инфраструктуру логов и тестов, третий на привычки команды. Попытки перескочить этапы заканчиваются откатами чаще, чем в этом признаются на ретроспективах.
Когда корутины всё ещё не лучший выбор
Линейный стиль не всесилен. Для коротких утилит с парой операций цена освоения не окупается, обычные функции или скромные обратные вызовы честнее и проще. На встраиваемых платформах с жёсткими лимитами памяти кадры на куче требуют отдельного аудита, а гарантий выделения кадра на стеке стандарт не даёт. Команды без инфраструктуры тестирования и логирования сначала получают новые виды ночных разбирательств и только потом, возможно, элегантность.
Ещё одна граница здравого смысла: корутины не ускоряют вычисления. Приостановка между шагами не даёт процессору бесплатного времени, она лишь позволяет одному потоку обслуживать много ожиданий. Вычислительная нагрузка ложится на пулы потоков по-прежнему, и смешение этих миров в одной корутине без понимания планировщика создаёт сюрпризы в виде остановленной магистрали.
И общий итог по зрелости. Механизм в компиляторах работает давно: Microsoft реализовала корутины раньше всех, ещё в Visual Studio 2017 экспериментальным режимом, Clang прошёл путь от технической спецификации в восьмой ветке до финальной семантики, GCC десять включал их отдельным флагом, а одиннадцатая ветка признала корутины частью обычного режима C++20. Библиотечная часть донастраивается до сих пор и продолжит до C++26. Значит, практический рецепт звучит так: использовать корутины там, где плотность ожиданий высока, брать чужие зрелые типы, а в глубокую механику нырять дозированно. Линейный код с co_await стоит дорого ровно в одном месте, в понимании того, что происходит между строчками, и вкладываться в это понимание заранее дешевле, чем разгребать последствия.