Аннотация
Когда комитет стандартизации проектировал корутины, за кадром остался один неловкий момент: фрейм корутины (coroutine frame) — это динамический объект. Локальные переменные, буферы под чтение, аргументы и состояние автомата с точкой останова
co_awaitдолжны где-то физически лежать, и по умолчанию компилятор выделяет это пространство из общей кучи через глобальныйoperator new. В асинхронном GUI, где корутина заводится на каждое нажатие клавиши и на каждое чтение из файла, это означает межпоточные блокировки кучи на каждом шаге с потенциальным переключением контекста — ровно то, от чего корутины обещали избавить.И это не единственный сюрприз: сам по себе
co_awaitасинхронности не даёт. Учебныйawaitableзапускает работу только в момент ожидания, и две асинхронные операции подряд на деле не запускаются асинхронно - идут с холостым кругом между ними; чтобы корутины стали по-настоящему асинхронными, механизм запуска операции пришлось отделить от того, что даёт язык. Эти вещи неочевидны, о них почти не пишут, и тем, кто присматривается к корутинам, стоит взглянуть на них в первую очередь.
План погружения
В третьей статье цикла о wxl разберём, как task, async_op и диспетчер sta_loop решают обе задачи из аннотации, попутно решив еще несколько вкусностей:
кадр берётся из однопоточного STA-аллокатора за три инструкции;
операция сразу уезжает в I/O-поток в момент вызова, а не в момент
co_await, локальные буферы корутины путешествуют вместе с ней, будучи выделенными из STA-пула;посчитаем, сколько на самом деле стоит кадр (и как первый замер оказался выдумкой из-за HALO);
выясним, почему пул «вечных корутин» не нужен;
посмотрим, как lock-free каналы амортизируют пробуждения так, что чем выше нагрузка, тем дешевле каждый вызов;
и, самое самое забавное, почему кадр корутины — лучшее место для хранения контекста GUI-приложения.
Начнём с той самой неловкости. Кадр на куче — не оплошность комитета, а прямое следствие того, что корутины C++ stackless: у приостановленной функции нет своего стека, и всё, что должно пережить приостановку, обязано лежать в объекте, который переживёт возврат из вызова. Да, стандарт разрешает компилятору не выделять кадр вовсе, если он видит всю его жизнь целиком и может положить его на стек вызывающего (эта оптимизация называется HALO — heap allocation elision).
Но в асинхронной схеме компилятор не видит полной жизни корутины никогда: хендл корутины уходит в канал и возвращается из другого потока. Так что кадр — на куче, и это не гипотеза, а закон жанра; ниже он ещё будет измерен.
Если GUI-приложение активно и асинхронно общается с сетью или файловой системой, корутины могут плодиться тысячами. Мы снова платим межпоточными блокировками кучи за абстракцию, которая обещала сделать код легковесным.
Здесь стоит уточнить про версии. Корутины появились в C++20; wxl живёт на C++23, и единственное C++23-изменение, которое корутин касается напрямую, — неявный move в co_return (P2266), это избавляет от необходимости писать co_return std::move(value) для move-only типов, снижая визуальный шум и страхуя от случайного копирования в обычном сценарии. Всё остальное в этой статье — механика, доступная любому компилятору с поддержкой C++20.
В первой статье цикла мы разобрали проекцию WinRT, избегающую многочисленных interlocked AddRef/Release, во второй — sta_memory_pool, аллокатор, который знает, что поток один, и потому в 15 раз быстрее общей кучи. Эта статья — про то, как эти два фундамента складываются в асинхронный код, который выглядит плоским и линейным, а стоит меньше, чем привычные колбэки.
Две корутины: task и detached_task
В wxl две корутинные обёртки, и разница между ними — не в том, где исполняется работа, а в том, кто держит кадр.
task — корутина, у которой есть владелец. Её кадр в конце тела не освобождается (final_suspend возвращает suspend_always), а остаётся владельцу: он вызовет done(), заберёт исключение через result() и сам освободит память, выделенную под кадр.
detached_task — корутина, которая владеет собой. Ничего не возвращает вызывающему, и кадр освобождается в тот момент, когда тело доходит до конца. Detached — в точности в смысле std::thread::detach(): работает независимо от того, кто её запустил, освобождает свои ресурсы сама, присоединить или остановить снаружи нельзя. И, как у отсоединённого потока, непойманное исключение из неё — конец процесса, если приложение не решило иначе. Именно её пишет почти весь GUI-код: цикл, который ждёт нажатий кнопки, не имеет ни результата, ни читателя, и заставлять вызывающего хранить хендл на сотню таких циклов — значит завести контейнер, который кому-то придётся обслуживать.
|
|
|
|---|---|---|
|
|
|
|
|
|
Что получает вызывающий |
хендл (move-only): |
ничего |
Куда идёт непойманное исключение |
хранится до |
в |
Кто освобождает кадр |
владелец, в деструкторе |
сама корутина |
Обе стартуют немедленно на вызывающем потоке: всё, что написано до первого co_await, выполнено к моменту возврата из вызова. Это не мелочь, а свойство, на которое можно опираться: подписка на событие, сделанная в первой строке корутины, уже живёт, когда вызывающий получил управление обратно.
А вот фундаментальный контракт у обеих один, и в документации task он выделен жирным: кадр берётся из sta_memory_pool, а значит, поток, которому принадлежит корутина, — это STA-поток пула. Пул начинает жить до первой корутины и переживёт последнюю. Это не ограничение, которое добавляют корутины: всё остальное в этой схеме выделяется там же, и корутине на любом другом потоке было бы попросту некуда класть свои операции.
class detached_task { public: struct promise_type { static void* operator new(std::size_t size) { return sta_memory_pool::alloc(size); } static void operator delete(void* mem, std::size_t size) noexcept { sta_memory_pool::free(mem, size); } ...
Фокус в перегрузке new и delete прямо внутри promise_type. Компилятор обязан вызывать именно их для размещения кадра. А поскольку объявлена sized-форма деаллокации, компилятор сам передаёт точный размер кадра обратно в sta_memory_pool, избавляя пул от необходимости помнить размеры блоков.
Виртуальный деструктор промису для этого не нужен, хотя вопрос напрашивается. Кадр — не полиморфный объект: код его создания и разрушения генерирует компилятор, и на обоих концах он знает точный тип и размер кадра. Стандарт ([dcl.fct.def.coroutine]/12) требует, чтобы при наличии обеих форм operator delete выбиралась двухаргументная и чтобы в неё передавался размер того самого блока, который запросил operator new. А вот async_op ниже виртуальный деструктор нужен по-настоящему: его удаляют через указатель на базу, и только удаляющий деструктор производного типа передаст пулу настоящий sizeof.
Осталось сказать, что происходит с исключением, — потому что это главное, чем отличаются две формы. Тело асинхронной операции task может бросить исключение в рабочем потоке I/O: оно захватывается в std::exception_ptr, едет обратно вместе с операцией и перебрасывается на co_await уже в потоке GUI, внутри тела корутины, где его можно поймать как своё. А если не поймали — наружу ему выйти некуда: корутину возобновил цикл sta_loop, и вылет из resume() унёс бы исключение в цикл, а в GUI — сквозь COM-делегат. Поэтому оно попадает в promise_type::unhandled_exception. У task тот просто сохраняет его для result(). У detached_task — отдаёт в on_detached_task_failure(): обработчик, который приложение вправе заменить, а по умолчанию ошибка пишется в stderr и процесс завершается — та же реакция, которую даёт корутина fire_and_forget в cppwinrt, но с одним важным отличием — возможности отмены асинхронной операции в wxl, о которой также буде ниже: для detached_task отмена не ошибка, а штатный сценарий.
? Путешествие буфера: чужой поток пишет в память STA-пула
Представьте обработчик в GUI-потоке wxl, который читает файл. Код выглядит привычно-линейным, человеческим и плоским:
detached_task dump(core::path path) { async_file file = co_await async_file::open_read(path); char buffer[64 * 1024]; while (size_t const n = co_await file.read(buffer)) std::cout.write(buffer, n); }
Давайте пошагово проследим за физикой этого процесса, помня, что sta_memory_pool — строго однопоточный, и его вызов из чужого потока просто разрушил бы память.
Старт. Корутина инициализируется в STA-потоке GUI; под неё из STA-пула выделяется кадр. «Локальный массив»
buffer— часть этого кадра.-
Операция уезжает — раньше, чем корутина остановилась. Вызов
file.read(...)— ещё неco_await. Он создаёт объект-операциюasync_op(тоже из STA-пула — у неё свойoperator newтуда же) и немедленно отправляет её рабочему потоку через lock-freespsc_channel. Только после этого выражение доходит доco_await, корутина приостанавливается, иawait_suspendзаписывает её хендл в операцию.?Вдумайтесь: операция ушла в другой поток, когда корутины, которую надо будет возобновить, в ней ещё нет. Более того — к моменту
co_awaitворкер мог уже всё исполнить и положить операцию в обратный канал. Именно поэтомуawait_ready()уawaitableвсегда возвращаетfalse, даже если результат уже готов: ответить «готово» значило бы оставить в обратном канале запись, в которой нет корутины. И именно поэтому вся эта гонка безопасна: единственный поток, который мог бы вынуть операцию из обратного канала и возобновить корутину, — STA-поток, а он прямо сейчас стоит внутри этого самого вызова и не может одновременно быть в цикле обработки ответов. Один инвариант однопоточности — и целый класс гонок исчезает без единого атомарного флага.И это не случайность, а решение — одно из самых выстраданных в схеме. Язык здесь ничего не подсказывает: он даёт три точки настройки —
await_ready,await_suspend,await_resume— и в учебных примерах awaitable почти всегда ленивый: работу он запускает изawait_suspend, когда корутина уже остановилась. Для одногоco_awaitособой разницы нет. Но стоит написать две операции подряд —auto a = file.read(first); // уже в очереди воркера auto b = other.read(second); // и эта тоже co_await a; co_await b;— и с посылкой из
await_suspendвторая ушла бы воркеру только после того, как первая вернулась: плюс целый круг через I/O и STA потоки на каждую пару.Чтобы корутина стала по-настоящему асинхронной — чтобы операции шли параллельно с кодом, который их запустил, и без холостых кругов между собой, — операцию пришлось отделить от awaitable и отправлять в момент вызова, а не в момент ожидания.
В этом есть дополнительный бонус, учитывая, что создание кадра корутины занимает время (хоть и малое), но создавать кадр всё равно требуется. Так пусть в параллель уже исполняется асинхронная операция!
Отсюда и отдельный объект
async_op: awaitable, лежащий в кадре, мог бы отправить себя лишь тогда, когда его адрес окончателен, то есть при приостановке. Цена отдельного блока — одно выделение из STA-пула, около 2 нс. А вариант «операция в кадре, посылка изawait_suspend» был прототипирован и замерен (никто не спит, оба ядра горячие): он оказался на 20 нс дороже, а не дешевле, как и ожидалось.Таким образом, лишний объект и лишний уровень косвенности с ним парадоксальным образом оказались быстрее. Парадокс, впрочем, кажущийся: в канал в обоих вариантах едет одно и то же — указатель, и воркер в обоих пишет в буфер, лежащий в кадре. Различие в порядке операций и в том, где лежат поля самой операции — тело, результат, исключение, хендл корутины, — а значит, чьи линии кэша ходят между ядрами.
Дополнительное объяснение лежит еще в самом пуле. Блок операции ничей: освободившись в конце
co_await, он уходит в LIFO-список и достаётся следующемуasync_call— чьей бы корутины тот ни был. В ping-pong режиме это буквально один блок на всю программу, горячий в кэше обоих ядер; в fan-out — плотный набор соседних блоков, который оба ядра проходят в порядке канала. Операция в кадре самой корутины такого обмена не дала бы по построению: кадр корутины A никогда не послужит операции корутины B, и между ядрами ходит по линии на каждую корутину в полёте, вразброс среди её локальных переменных.Таким образом, начав решать одну задачу (тру-асинхронности), даром получили еще пару бонусов.
Работа на чужбине. Рабочий поток просыпается, забирает операцию и молотит данные.
ReadFileпишет байты прямо в нашbuffer— в память «хрустального» STA-аллокатора, способного уронить программу от одного неверного вызова. Физически буфер сейчас в руках чужого потока, но воркер только пишет по сырому адресу. Сам аллокатор он не трогает.Возврат домой. Воркер закончил чтение и через обратный канал
from_worker_возвращает исполненную операцию, пиная при надобностиsta_signal: «я всё, возвращаю корутину».Финал по RAII. STA-поток вынимает операцию из канала и возобновляет корутину там же, где она стояла. Если файл дочитан — цикл завершается. Если чтение упало — исключение с рабочего потока перебрасывается на
co_await, и тело либо ловит его, либо раскручивается до конца. В обоих случаях локальные объекты кадра — открытый файл и всё остальное, у чего есть деструктор, — уничтожаются в обратном порядке, как при выходе из любого блока: это классическое RAII, только в теле, которое успело побывать невесть где, а сейчас живёт приостановленным зомби. Затемoperator deleteвозвращает память кадра, а с ней иbuffer, в однопоточный STA-аллокатор. Объект-операция умирает ещё раньше: он принадлежитawaitable, который лежит в кадре, и разрушается вместе с концом выраженияco_await.
?Вдумайтесь в изящество этого круговорота. Память буфера путешествовала между потоками и физически эксплуатировалась системным I/O в чужом потоке, но выделена и освобождена она была в границах одного и того же STA-потока. Строгий контракт однопоточности аллокатора не нарушен, interlocked-операций на аллокации по-прежнему ноль, а асинхронный автомат написан в привычном линейном стиле.

Архитектурный паттерн wxl здесь не уникален — он лишь наглядно демонстрирует внутреннюю механику корутин и показывает, как их правильно готовить. Мы получаем удовольствие от линейного асинхронного кода, не платя налог на производительность.
Но и это ещё не всё. Бинго в том, что теперь внутри асинхронного обработчика можно выделять «на стеке» сколько угодно памяти. У корутины ведь нет собственного физического стека — вместо него тот самый динамически выделяемый кадр. Поэтому в примере выше мы могли написать даже так:
// 10 МБ на обычном стеке не поместятся (у потока Windows он по умолчанию мегабайт)... char buffer[10 * 1024 * 1024]; // ...но внутри корутины — без проблем
Кадр такого размера, конечно, пройдёт мимо свободных списков пула — по трёхуровневой схеме из прошлой статьи он уйдёт в обычный malloc. И это правильно: способ выделения теряется на фоне цены доступа к холодной памяти такого объёма, так что здесь пул уже ничего бы не выиграл. Выигрыш в другом — в самой возможности.
?Ещё раз вдумайтесь, что это даёт на практике: мы получаем право располагать тяжёлые объекты и буферы прямо по значению в локальных переменных корутины, убирая косвенность обращения к памяти. Там, где в классическом коде мы возились бы с умными указателями и писали привычную бойлерплейт-рутину, компилятор теперь собирает плоскую структуру внутри STA-арены. Эффективность растёт, когнитивная нагрузка на разработчика падает, внутренний перфекционист удовлетворён.
? Архитектурный мост: sta_loop и амортизация пробуждений
Встречным движением между оранжерейным STA-миром и суровой I/O-реальностью заведует sta_loop. Он устроен как два встречных SPSC-канала: операции улетают воркеру через to_worker_, готовые ответы прилетают обратно через from_worker_. Никаких разделяемых ресурсов, кроме самих объектов-операций, каждый из которых в любой момент принадлежит строго одному потоку.
В этой легковесной схеме нас поджидает знакомый штраф на пробуждение потока-воркера или при путешествии готовой задачи обратно - на пробуждении GUI-потока. Оба раза через вызовы ядра для дёргания примитива ожидания в I/O-потоке или GUI-диспетчера сообщений. Если воркер будет дёргать диспетчер GUI на каждый мелкий прочитанный кусок файла, GUI-поток захлебнётся в переключениях контекста.
Здесь sta_loop делает так, чтобы на стороне GUI run_pending() разгребал сразу всё, что накопилось (ниже упрощённо, полная версия — в wxl.async/src/sta_loop.ixx):
size_t run_pending() { size_t resumed = 0; for (async_op* op = nullptr; from_worker_reader_.read(op); ++resumed) op->resume(); return resumed; }
Когда воркер возвращает пачку завершённых операций, тяжёлый вызов диспетчера GUI срабатывает один раз на всю пачку. Поток интерфейса просыпается, заходит в run_pending() и одним циклом размораживает все корутины, чьи ответы успели накопиться. Внутри этого цикла на каждый элемент не приходится ни одной атомарной RMW-операции: spsc_queue работает на упорядоченных записи и чтении, без interlocked CAS и exchange.
Спойлер: в одной из следующих статей будет подробный обзор применённых трюков в области lock-free. Для нас сейчас важно лишь то, что воркер знает, что диспетчер уже разбужен и повторно будить его не надо до тех пор, пока он снова не заснёт. Цена такого протокола — один interlocked exchange и один барьер на пачку, а не на операцию, и она делится на размер пачки.
Замер в wxl.async/benchmarks/sta_loop_benchmark.cpp, сценарий driven (диспетчер в бенчмарке заменён событием ядра, так что считаются не наносекунды диспетчера, а число обратных вызовов на операцию):
корутин в полёте |
вызовов диспетчера на операцию |
нс на операцию |
|---|---|---|
1 |
1.000 |
11 100 |
8 |
0.125 |
1 385 |
64 |
0.034 |
304 |
Одна корутина — один вызов на операцию, деваться некуда: каждый ответ будит поток. Восемь — уже один вызов на восемь. Шестьдесят четыре — один на тридцать, и цена операции упала в тридцать шесть раз. Настоящий DispatcherQueue дороже события ядра, так что в GUI экономия ещё заметнее.
Это и есть общий эффект, ради которого вся конструкция построена: чем выше нагрузка, тем дешевле система работает в пересчёте на единичный асинхронный вызов. Не просто «выдерживает нагрузку», а существенно дешевеет под ней. В сравнительных тестах корутины wxl на 64-х асинхронных операциях отработали в ~30 раз быстрее стандартных корутин cppwinrt. На редких-одиночных операциях разница малозаметна, что ожидаемо.
? Сколько стоит кадр — и почему пул корутин не нужен
Раз корутина заводится на каждую задачу, естественно спросить: не дорого ли создавать и разрушать кадр? Не завести ли пул «вечных» ожидающих корутин — свободная берётся из стопки, задание исполняется в ней, по завершении она возвращается в пул?
Первый довод — языковой, и он закрывает вопрос раньше замера. co_await внутри лямбды делает корутиной саму лямбду — со своим кадром и промисом, — а не ту корутину, что её вызвала: stackless есть stackless. Так что «задание исполняется в готовой корутине» распадается на два несовместимых чтения: либо задание — обычная лямбда, и тогда оно не умеет ждать, то есть не может сделать ровно то, ради чего корутина заводится; либо задание — само корутина, и тогда кадр у него всё равно свой, а пул переиспользует лишь внешнюю глупую оболочку. А ещё вечной корутине надо как-то передать очередное задание — через поле стёртого типа вроде core::function<void()>, а стирание типа — это new на каждое задание, то самое динамическое выделение памяти, ради отмены которого пул затевался.
Второй довод — измеренный, wxl.async/benchmarks/coroutine_frame_benchmark.cpp. Release, один поток, одинаковая работа на задачу (по непрозрачному вызову с каждой стороны единственного засыпания), так что различается только происхождение кадра и его размер:
одна задача |
нс |
кадр |
|---|---|---|
своя корутина, кадр из |
60–62 |
32 Б |
своя корутина, кадр из |
16 |
32 Б |
то же с килобайтом локальных, |
66–75 |
1072 Б |
то же с килобайтом локальных, из пула |
22 |
1072 Б |
вечная корутина, только |
5.8 |
— |
Кадр из пула стоит около десяти наносекунд сверх переиспользованной корутины, толстый кадр добавляет ещё пять-шесть. А вот кадр из пула против кадра из CRT-кучи — втрое, и это окупается на каждой задаче. В любом случае, какая бы конечная версия на прижилась бы в итоге под капотом wxl, пользовательский код не потребует изменений.
И еще кое-что: первые числа этого замера были выдумкой. Таблица показывала 12.8 нс за кадр из new/delete и 12.5 из пула — «пул не даёт ничего», — компилятор волен не выделять кадр вовсе, когда видит, что тот умирает внутри вызывающего (тот самый HALO), а в цикле бенчмарка он это видел. Мерилось создание объекта на стеке. Размер фрейма оказался ни при чём — килобайт локальных байт элидировался так же охотно, как их отсутствие; решает то, виден ли вызывающему жизненный цикл кадра.
Теперь в бенчмарках operator new считает, сколько кадров на самом деле дошло до аллокатора (из цифр в таблице стоит отнять в уме ~1 нс одинаковых для всех затрат на обслуживание счётчика), и колонка frames/job печатается рядом с наносекундами — прогон, у которого там ноль, меряет несуществующий сценарий. Во-вторых, noinline на самих корутинах: кадр обязан пережить возврат из вызова, который оптимизатор не раскрывает, и деться ему больше некуда (поэтому, из цифр выше можно отнять еще ~1 нс).
Тем же счётчиком поймана и вторая подделка, поменьше: колонка размера показывала у «килобайта локальных» кадр в 48 байт вместо 1072 — MSVC оставил от массива два байта, которые код и правда читает. Массив пришлось отдать наружу, в непрозрачный вызов, и только тогда он попал в кадр весь.
Мораль для тех, кто меряет корутины: без счётчика реальных аллокаций любой такой бенчмарк — гадание. И да, убрать из бенчмарка небольшие общие для всех затраты чистоты измерений ради — значит потенциально вернуться к той версии, которая мерила неизвестно что.
?️ Фрейм корутины как убежище для контекста
Теперь вся картина сходится, и переход от событийной лапши колбэков к корутинам оказывается не данью моде, а способом решить время жизни структурно — без тяжёлых умных указателей и без дисциплины «не забудь захватить в лямбду требуемые переменные по значению».
В классическом GUI на лямбдах программист вынужден постоянно думать: «Если я захвачу объект по значению, не скопирую ли мегабайт данных? А если по ссылке — не протухнет ли стек к моменту клика?» К тому же, перечисляемый явно список захвата требует квантов внимания на актуализацию при изменениях кода.
В wxl этот дуализм снят тремя фактами:
Легковесность по умолчанию. Все публичные объекты-проекции (
Button,Border,Grid) — дешёвые копируемые хэндлы размером с указатель, которые не несут в себе полей данных, а лишь ссылаются на внутренние цепочкиImpl. Их копирование стоит столько же, сколько копирование сырого указателя. «Интрузивный счётчик ссылок, как в COM», скажете вы, и будете почти правы. Почти — потому что в COM каждая параAddRef/Release— это два виртуальных вызова и две interlocked-операции. В wxl полученный однажды COM-объект вместе с кешем его интерфейсов живёт вwxl::core::refcountedкак инлайный счётчик без interlocked (поток один — см. прошлую статью) и виден компилятору насквозь, так что парные инкремент и декремент при передаче по значению оптимизатор вправе схлопнуть.Кадр вместо висящих ссылок. Когда обработка переводится на корутины, эти лёгкие объекты передаются в асинхронные функции по значению и ложатся внутрь кадра.
Живы, пока жива корутина. Кадр существует, пока корутина не завершилась. Пока она стоит на строке
co_await keys, всё в её кадре достоверно валидно. Кадр стал естественным контейнером контекста, который держит его ровно столько, сколько идёт асинхронный процесс, — и ни на такт дольше.
Было: капкан лямбд
Window build_window() { // Счётчик нужен двум обработчикам на двух разных объектах, а стек // build_window умрёт при возврате -- значит, куча и shared_ptr. auto pressed = std::make_shared<int>(0); return Window { // точечный захват переменной в лямбду onClosed = [pressed] { log.info("Нажатий: {}", *pressed); }, content = Border { isTabStop = true, // И разнесённые по исходнику два обработчика события на одну // целевую логику, где между ними может быть несколько экранов кода, // либо же они могут и вовсе разойтись по разным файлам. onPreviewKeyDown = [pressed] { ++*pressed; }, }, }; }
Стало: контекст в кадре корутины
async::detached_task count_presses(Border const& keypad) { auto keys = onPreviewKeyDown(keypad); // подписка -- в кадре int pressed = 0; // счётчик -- в кадре while (co_await keys.next()) ++pressed; // Здесь приложение закрывается либо происходит событие onUnload: // вышли из цикла и дальше обычный линейный код. Кадр разрушится // в GUI-потоке, подписка снимется в деструкторе keys. log.info("нажатий: {}", pressed); } ... Border { isTabStop = true, onLoaded = [](Border const& keypad) { count_presses(keypad); }, }
Здесь while (co_await keys.next()) — и есть ответ на вопрос, как узнать, что пора выходить. Метод next() возвращает значение следующего типа-матрёшки:
std::expected<T, std::optional<std::exception_ptr>> — само событие или причину, по которой его больше не будет. В условии while он неявно приводится к bool.
Сам идентификатор next() пришёл из практик реактивных фреймворков, и в wxl мы рассматриваем его как аналогичный yield-генератор событий. Никакие токены отмены или конструкции try/catch здесь не нужны, потому что отмена приходит через само ожидание — тем же путём, что и штатное событие. Это сквозной принцип wxl: отмена — такой же легитимный исход, как значение или ошибка, и едет она по тем же рельсам. Точно так же цикл:while (n = co_await file.read(buffer)) естественным образом завершается по концу файла.
Однако здесь кроются две важные оговорки, которые стоит озвучить.
Оговорка 1: Отмена vs Ошибка (Разделяй и властвуй)
Короткая форма while(co_await keys.next()) намеренно не отличает отмену операции от ошибки — оба исхода возвращают false и завершают цикл. Если же вашей логике требуется отличать одно от другого, то это привычный паттерн обслуживания optional-значения:
auto const expectedKey = co_await keys.next(); if (!expectedKey) { if (auto maybeError = expectedKey.error()) std::rethrow_exception(*maybeError); // Пустая ошибка для unexpected означает «операция отменена» break; }
У wxl есть и классическая бросающая форма без .next() — прямой co_await keys. Но для нашей задачи подсчета нажатий она не подходит. При закрытии окна бросающая форма честно выкинет наружу operation_canceled_exception. Вышестоящий detached_task послушно проглотит его как штатное завершение работы, но строка после цикла log.info(...) не выполнится. Бросающая форма идеальна для сценариев, где после отмены корутине больше нечего делать или же когда логика финализации обыгрывается деструкторами локальных для кадра корутины RAII-объектов.
Оговорка 2: Капкан циклических ссылок, которого нет
Вернемся к первому (классическому для cppwinrt) варианту на лямбдах. Помимо визуального шума, в нем заложена досадная неприятность — утечка памяти из-за замыкания refcounted счетчиков. Стоит лямбде-колбэку захватить по значению интерфейсный объект, который прямо или транзитивно удерживает саму эту лямбду (например, обработчик события кнопки, захвативший эту самую кнопку), — и этот граф объектов не освободится в памяти никогда. Это одна из популярных ошибок при программировании GUI-приложений.
Выход из этого капкана в C++ общеизвестен, и оба пути вызывают головную боль:
Использовать слабые ссылки (
make_weak), что превращает код в набор церемоний по их проверке и разыменованию.Переходить на строгий императивный стиль подписок с генерацией уникальных токенов (
EventRegistrationToken), которые еще нужно придумать где хранить, как учитывать и как не забыть применить их при явной отписке от событий.
Наш исходный пример «Было» на лямбдах в реальности промышленно-готового кода должен был быть длиннее и тяжелее, просто не хотелось перегружать статью бойлерплейтом, который всё равно подтребовал бы ровно этих объяснений.
В корутинном варианте wxl циклическая ссылка невозможна по построению.
Внутренний обработчик, который объект keys вешает на keypad, захватывает не тяжелый хэндл элемента, а сырой указатель на подписку, лежащую в кадре. Стрелка владения одна: кадр корутины знает про keypad и удерживает его от уничтожения, но keypad про кадр ничего не знает! Подписка автоматом снимется в деструкторе keys, когда автомат завершит работу и кадр умрет. При этом сам keypad заведомо жив, пока на него ссылается кадр. Никаких токенов, никаких слабых ссылок, никакой ручной отписки.
Это иллюстрация того, чем wxl отличается от «простого синтаксического сахара» над WinUI 3: типовые задачи GUI решаются в wxl без компромиссов — и в том, как код выглядит, и в том, что происходит под капотом.
Главный гуишный инвариант wxl формулируется так:
Передавайте и располагайте объекты по значению, пишите линейные корутины — и время жизни контекста решит компилятор. Кадр корутины — это ваш новый стек: быстрый, потому что берется из STA-пула; чистый синтаксически, потому что избавляет от необходимости обслуживать shared-объекты; и безопасный по памяти, потому что живет ровно столько, сколько идет асинхронный процесс, который он описывает.
⛓️ Цена контракта: корутина обязана закончиться
У отсоединённой корутины есть цена, и её надо назвать. detached_task никто не держит — значит, никто не может её остановить. Единственный выход — через тело. А это означает, что каждое ожидание в ней должно быть на чём-то, что умеет заканчиваться. Awaitable, у которого нет способа завершиться, молча оставил бы кадр висеть до конца процесса.
Событийные ожидания wxl это умеют. Пока корутина стоит на co_await keys, её ожидание записано в один общий интрузивный список — узел лежит прямо в кадре, так что список ничего не выделяет. Список — это множество корутин, которые сейчас чего-то ждут, а при выгрузке окна или выходе из приложения — множество тех, кому надо сказать, что ждать больше нечего.
И сказать — не значит уничтожить. У корутины может быть транзакция, которую надо откатить, или сокет, который надо закрыть вежливо, что иногда порождает дополнительную асинхронную работу: в деструкторе она невозможна, только в теле. Поэтому выход приложения — те же две фазы, что у любого потока в wxl:
Фаза первая: пока цикл сообщений ещё крутится, ожидание в каждой живой корутины возобновляется с описанным выше протоколом «событие не придёт»; бросающая форма превращает её в
operation_canceled_exception, отвечающая — в пустую ошибкуstd::expected. Что корутина с этим сделает — откатит, закроет, сохранит — исполняется здесь, на живом цикле.Фаза вторая: цикл обработки задач крутится, пока список живых корутин не опустеет. Именно поэтому
detached_task::unhandled_exceptionглотает отмену как штатный конец, а не как ошибку, и при правильном написании тела корутины (в духе привычного С++ с RAII объектами по значению), прикладные ресурсы будут освобождены.
? Ложка дёгтя: MSVC и кадр, который не умер
Всё вышесказанное про RAII в кадре верно по стандарту. А теперь про компилятор.
Форма с владельцем — task, у которой final_suspend возвращает suspend_always, а кадр освобождает тот, кто его держит, — не задета дёгтем: там всё работает как ожидалось. Изначально в wxl была только такая корутина, и всё было хорошо. Потом появилась вторая, detached_task, «выстрелил и забыл», — и полдня выходного прошло в стиле «то ли лыжи не едут, то ли…»
В итоге появились тесты, которые проверяют то, что у приличных людей проверять не принято — что у локального объекта вызывается деструктор.
Изолированная проба на голом <coroutine> показала, что дело не в wxl. На x64 под любой оптимизацией (/O1, /O2, /Ox) и синхронной моделью исключений (/EHs, /EHsc) корутина, у которой final_suspend возвращает suspend_never, при выходе из тела пропускает деструкторы локальных объектов и вообще не освобождает кадр — operator delete промиса тоже не вызывается. /Od — корректно; /EHa — корректно; /O2 /Ob0 (без инлайнинга) — корректно.
Дефект проявляется тогда, когда компилятор достоверно видит, что в теле корутины не выбрасываются исключения. Стоило добавить недостижимый if (never) throw, либо добавить вызов функции из другой единицы трансляции (чьё тело оптимизатор “не видит”) или авейтера, члены которого не noexcept, — и деструкторы возвращаются, а с ними и деаллокация. Это в точности условие, при котором документация /EH разрешает синхронной модели «не отслеживать время жизни многих раскручиваемых объектов», — только здесь вместе с отслеживанием исчезает и обычная раскрутка условного стека корутины.
Репорт с repro и таблицами флагов отправлен в Microsoft: x64, optimized, synchronous exception model: a coroutine whose final_suspend() returns suspend_never skips the destructors of its locals and leaks its coroutine state. Компилятор — 19.51 из Visual Studio 2026 Insiders.
Как с этим жить, пока не починят? Самый простой воркэраунд — собрать с /EHa: работает, пусть даже меняет модель исключений (catch (...) начинает ловить SEH, растут таблицы раскрутки). Остаётся надеяться, что в ближайших релизах компилятора этот досадный баг будет вычищен.
Куда дальше
WinUI 3 без XAML — первая статья цикла: проекция, сжатая в 50 раз, и настоящее наследование вместо
.as<T>().WXL: как создавать объекты в 15 раз быстрее —
sta_memory_pool, на котором стоят кадры.Впереди — устройство каналов, по которым ездят операции:
spsc_queue, которая «дышит»; протоколarm/disarm, благодаря которому пробуждение никогда не теряется;future, у которой продолжения приходят задом наперёд, и это всех устраивает.
Комментарии (11)

mayorovp
22.09.2026 18:34Автор, ну за что ты так с нами? Интересная же статья, только за нейрослопом нихрена не понятно. Как насчёт того, чтобы самому прочитать что тут написано?
В третьей статье цикла о wxl разберём […]
Нельзя так начинать статьи! Что такое wxl? Гуглятся всероссийская хокейная лига, формат файла и музыкальный исполнитель. Это твоя библиотека? Или просто что-то очень свежее? Я мог бы посмотреть ответ в прошлых двух статьях, но на них нет ссылок.
Начнём с той самой неловкости. Кадр на куче — не оплошность комитета, а прямое следствие того, что корутины C++ stackless:
Простите, а кто-то считает, что кадр на куче - оплошность комитета?
Обе стартуют немедленно на вызывающем потоке: всё, что написано до первого co_await, выполнено к моменту возврата из вызова. Это не мелочь, а свойство, на которое можно опираться
Кто-то называл это свойство мелочью? Да с кем вы спорите-то?
Пул начинает жить до первой корутины и переживёт последнюю. Это не ограничение, которое добавляют корутины: всё остальное в этой схеме выделяется там же, и корутине на любом другом потоке было бы попросту некуда класть свои операции.
И третий раз уже. Это уже не случайность, а закономерное следствие использования бестолковой нейронки для написания текста.
Ну серьёзно, блин. Поручите вы своим агентам вычитать текст и убрать все обороты “это не FOO, а BAR”, с этим любая модель справится. Даже та, которая нагенерировала их минуту назад.

vdimas Автор
22.09.2026 18:34>Простите, а кто-то считает, что кадр на куче - оплошность комитета?
Ккорутинами пользовались задолго до появления в стандарте, и это были были stackful-корутины. Заостриться на этом стоило, ИМХО, потому что тему корутин, судя по виденным обсуждениям, пока еще нельзя считать "окончательно понятной" для приличной части разработчиков.
Кто-то называл это свойство мелочью? Да с кем вы спорите-то?
Было обсуждение на RSDN, язык мой, можно убедиться.
http://www.rsdn.org/Forum/?uid=21096
Нейронки проверяют грамотность, сглаживают сленг и шероховатость, предлагают синонимы вместо самоповторов и т.д.И третий раз уже. Это уже не случайность, а закономерное следствие использования бестолковой нейронки для написания текста.
Это отсылка к предыдущей статье:
https://habr.com/ru/articles/1082702/
И плюс моя привычка отвечать на потенциальные вопросы заранее. Вопрос времени жизни пула критичен, у внимательного читателя могли возникнуть закономерные вопросы.
mayorovp
22.09.2026 18:34Было обсуждение на RSDN, язык мой, можно убедиться.
Если бы вы хотя бы сослались на то обсуждение - отрицание было бы уместно. А так оно выглядит ни к селу, ни к городу. Но знаете что на форумах я ненавижу больше всего? Читать холивар с середины, когда для понимания каждого сообщения требуется сначала прочитать предыдущее, и так рекурсивно на 10 лет назад. Как насчёт того, чтобы просто прервать эту цепочку?
Что было на RSDN - осталось на RSDN, вы же пишете статью на Хабре. Даже не комментарий, а статью, сообщение самого верхнего уровня.
Нейронки проверяют грамотность, сглаживают сленг и шероховатость, предлагают синонимы вместо самоповторов и т.д.
А ещё добавляют бессмысленные риторические приёмы там, где достаточно простого утверждения.
Я сам пользуюсь нейронками для составления документации, и знаю как хорошо они умеют как вычитывать и полировать тексты, так и писать нечитаемую белиберду на своём нейросуржике.

vdimas Автор
22.09.2026 18:34ОК. ))
Несколько топовых нейронок выдали идентичный вердикт, что мой слог суховат для Хабра и его стоит разбавить. Основное время, потраченно на статьи, ушло на это.
И еще ж цель не просто дать материал, а сделать его интересным тем, кто только изучает всё это. Это повлияло на ритм повествования и на саму последовательность раскрываемого. В итоге, от каждой статьи требуется "немедленный результат", а не как в многосерийном детективе, где "всё сошлось только на последних 5 минутах последней серии". ))
Буду благодарен за любые полезные советы, на этой стезе я впервые.

mayorovp
22.09.2026 18:34Теперь по содержанию статьи.
Именно поэтому await_ready() у awaitable всегда возвращает false, даже если результат уже готов: ответить «готово» значило бы оставить в обратном канале запись, в которой нет корутины.
Сколько не вдумывался, так и не понял что такое “запись в которой корутина” и в чём сложность отменить эту запись. Самый интересный момент всей статьи не раскрыт совершенно.
Отсюда и отдельный объект async_op: awaitable, лежащий в кадре, мог бы отправить себя лишь тогда, когда его адрес окончателен, то есть при приостановке
Снова что-то интересное и без подробностей. Фактически, async_op - третий вид возвращаемых значений. Как он ведёт себя при вызове деструктора без co_await? Это ошибка? Отмена задачи? Просто игнорирование результата?
Парадокс, впрочем, кажущийся: в канал в обоих вариантах едет одно и то же — указатель, и воркер в обоих пишет в буфер, лежащий в кадре
А вот и критическая проблема прокралась. Что случится, если корутина прекратит работу, пока асинхронная операция что-то делает с буфером, лежащим в кадре? Кажется, ничего хорошего.
Теперь вся картина сходится, и переход от событийной лапши колбэков к корутинам оказывается не данью моде, а способом решить время жизни структурно — без тяжёлых умных указателей и без дисциплины «не забудь захватить в лямбду требуемые переменные по значению».
Вот не верю что реализация async_op обошлась без умного указателя. Ну и по поводу захвата переменных по значению - что, даже к this из корутины можно обращаться без учёта жизненного цикла этого this?
При этом сам keypad заведомо жив, пока на него ссылается кадр. Никаких токенов, никаких слабых ссылок, никакой ручной отписки.
А вот и неправда. Ссылка вида
Border const& keypad, использованная у вас в примере, потенциально умирает после первого же co_await.Если у вас Border и правда легковесный внутрипоточный умный указатель - его надо передавать строго по значению. А если нет - надо таки использовать те самые легковесные умные указатели, которыми вы хвастались ранее.
Awaitable, у которого нет способа завершиться, молча оставил бы кадр висеть до конца процесса.
Смотря что считать под “завершением”. Возьмём вот такой Awaitable:
struct never_complete { bool await_ready() noexcept { return false; } void await_resume() noexcept {} void await_suspend(std::coroutine_handle<> handle) noexcept { handle.destroy(); } }Эта операция никогда не завершается, но и не оставляет кадр висеть до конца процесса.
Он устроен как два встречных SPSC-канала: операции улетают воркеру
Стоп, што?! У вас один воркер? Даже не по числу ядер?
И, если у вас строго SPSC-очереди, то вы даже сторонние Awaitable-объекты не поддерживаете? Как-то потенциально полезная библиотека резко превратилась в наколеночную поделку…

vdimas Автор
22.09.2026 18:34Сколько не вдумывался, так и не понял что такое “запись в которой корутина” и в чём сложность отменить эту запись. Самый интересный момент всей статьи не раскрыт совершенно.
Добавил диаграмму. А в статье есть ссылка на исходники. Суть в том, что корутина между потоками не ездит, ездит всмпомогательный объект async_op, который, виду паралельности происходящего, мог быть готов к моменту, когда корутина впервые подошла к co_await.
В этом месте происходит классическая вилка асинхроного сценария: возвращать ли готовое значение сразу или на следующем обороте диспетчера. Это отдельная немаленькая тема, засорять которой статью не хотел. В нынешней реализации в любом случае происходит выход (приостановка) в точке co_await и тут же заход диспетчером обратно, если асинхронная операция уже готова.Снова что-то интересное и без подробностей. Фактически, async_op - третий вид возвращаемых значений.
Это приятно и неожиданно, что кому-то интересны столь глубокие подробности. Кратко если, надо было принять непростое решение - гонять ли кадр корутины в другой поток или нарисовать такой механизм, который этого не требует. Позиционирование библиотеки - для использования программистами начального уровня, студентами, непрофильными специальстами и т.д. Это жестко ограничивает в манёврах вокруг выставляемого АПИ. ))
А вот и критическая проблема прокралась. Что случится, если корутина прекратит работу, пока асинхронная операция что-то делает с буфером, лежащим в кадре? Кажется, ничего хорошего.
Stackless-корутина не может "сама что-то прекратить" - это пассивный автомат, его надо пинать откуда-то. Ручка корутины в публичном коде недоступна, а под капотом её дергают только из GUI-потока.
Вот не верю что реализация async_op обошлась без умного указателя.
И тем не менее:
https://github.com/dmitry-valyukov/wxl/blob/main/wxl.async/src/async_op.ixx
Это просто объект из кастомной кучи, который посылают по указателю в другой поток и точно так же принимают обратно. Его хранят, а он может хранить, а может нет - корень своей иерархии, где один из потомков хранит лямбду без стирания типов.А вот и неправда. Ссылка вида
Border const& keypad, использованная у вас в примере, потенциально умирает после первого же co_await.Асболютно верно! Это легковесный фасад над настоящим COM-объектом, он нужен только для операции подписки на GUI-событие и волен умереть с чистой совестью. Подробности в начале второй статьи цикла:
https://habr.com/ru/articles/1082702/Эта операция никогда не завершается, но и не оставляет кадр висеть до конца процесса.
Но эта операция никогда и не начинается ))
Ловко,handle.destroy()прямо вawait_suspend, зачёт.Стоп, што?! У вас один воркер? Даже не по числу ядер?
Оу, здесь основная интрига всего проекта.
И, если у вас строго SPSC-очереди, то вы даже сторонние Awaitable-объекты не поддерживаете? Как-то потенциально полезная библиотека резко превратилась в наколеночную поделку…
Пул "обычных воркеров" cppwinrt доступен, конечно, как и обслуживание их корутин в диспетчере (АПИ диспетчера прокинуто в публичную видимость обёрток).
Тут наоборот - желание привнести хардкорный подход в "классический GUI", заикающийся в простейших сценариях.
Для I/O-bound операций важными оказываются накладные расходы на сигнализацию и передачу данных м/у потоками. Улучшив этот показатель в ~30 раз в сравнении с "обычным cppwinrt" закрывается любой асинхронный ввод-вывод. Дополнительные потоки освобождаются для числодроблений, т.е. для того, для чего и нужны "много ядер". И эти потоки доступны как из GUI, так и из встроенных async_op (если понадобится). Будут эксперименты с асинхронщиной и без дополнительных потоков вовсе. Жаль, что файлы не умеют открываться асинхронно... Но наработок по completion routines хватает и они заметно эффективнее IOCP, какими бы трюками это IOCP ни обкладывалось. IOCP про масштабируемость, а не про эффективность.
Т.е. использование множества ядер сугубо для I/O, где при правильной настройке тех же сокетов можно получать готовность только когда данные уже де-факто лежат в поданном буфере и требуется только максимально эффективно организовать реакцию на это - имеет свою цену. В коде есть lock-free mpsc-очередь, тоже весьма эффективная (и по ней будет статья), но чудовищно уступает показанной реализации spsc, которая вдвое натягивает схему LMAX Disruptor (на моей машине прогоняет почти 2 млрд элементов/сек между потоками).
Да, почти все трюки из той области. Началось как фан из разряда "а что если..."
(попробовать забивать кривые гвозди электронным микроскопом)
Ну и плюс многолетнее недовольство происходящим в GUI как таковом... ))
Cheater
Не совсем поэтому, в Rust например тоже stackless корутины, однако там по умолчанию аллокации для фрейма корутины не происходит тк rustc в идиоматическом случае компилирует .rs код и вызывающего и вызываемого и гарантированно встраивает coroutine frame по месту вызова. В C++ же такая роскошь лишь опциональна (HALO) и в общем случае вызывающий живёт в другой единице трансляции.
vdimas Автор
А что происходит, если фрейм дочерней корутины должен пережить кадр стека вызывающего сайта?
mayorovp
Он явно упаковывается в коробку (Box) или другой подходящий контейнер. Как-то так (точный синтаксис не помню):
vdimas Автор
Я любопытствовал про физику процесса, ведь вызвающий сайт выйдет из подпрограммы и освободит стек инкрементом SP. Т.е., происходит ли при этом перемещение содержимого кадра, как оно происходит при move лямбд С++?
В С++ при этом можно нарваться на невалидность ссылок на самих себя внутри кадра переменных лямбды. ))
Cheater
Если не продлить его жизнь через заворачивание в Box и т.п., то не скомпилируется тк борроу чекер выдаст ошибку нарушения времени жизни