Сегодня попробую рассказать о тех моментах в механизме поддержки короутин C++20, которые вызывают наибольшие сложности и пока никак толком не укладываются в моих мозгах. Зачем я вообще ввязался в изучение короутин из C++20 объяснялось в предыдущем посте.
Целью является попытка структуризации изучаемой информации. Есть надежда, что формулирование разрозненных мыслей в письменном виде принесет пользу (пока что это предположение оправдывается). Но, возможно, перечисленные мной пункты окажутся актуальными еще для кого-то.
В конце поста я приведу список материалов на тему C++20 короутин, которые я счел полезными и штудированием которых занимаюсь вот уже неделю.
Что вызывает сложности и с трудом укладывается в мозгах
Непонимание того, о какой именно короутине сейчас идет речь
Примеры, которые разбираются в статьях про короутины, часто выглядят вот так:
task bar() { ... // Что-то там. co_yield ...; ... } task foo() { ... co_await bar(); ... } |
И идет подробный разбор того, во что превращается вызов co_await bar() внутри foo().
При этом у меня в голове фиксируется, что короутина здесь -- это bar. И, соответственно, все рассуждения про Awaiter, который создается для co_await bar(), для меня начинают соотноситься с bar.
Из рассмотрения выпадает тот факт, что foo -- это тоже короутина. И что когда co_await bar() раскладывается вот на такой вот код:
{ auto&& value = bar(); auto&& awaitable = get_awaitable(promise, static_cast<decltype(value)>(value)); auto&& awaiter = get_awaiter(static_cast<decltype(awaitable)>(awaitable)); if (!awaiter.await_ready()) { using handle_t = std::experimental::coroutine_handle<P>; using await_suspend_result_t = decltype(awaiter.await_suspend(handle_t::from_promise(p))); <suspend-coroutine> if constexpr (std::is_void_v<await_suspend_result_t>) { awaiter.await_suspend(handle_t::from_promise(p)); <return-to-caller-or-resumer> } else ... <resume-point> } return awaiter.await_resume(); } |
то все это относится не к короутине bar, а к короутине foo. Т.е. все вот эти вот suspend-coroutine, return-to-caller-or-resumer, resume-point -- это все исключительно про foo, а не про bar.
Более того, до меня не сразу дошло, что во всей той простыне кода, в который превращается co_await bar(), в качестве экземпляра promise используется объект, который был создан при входе в foo. И что этот promise не имеет к короутине bar никакого отношения.
Маленькое фи в адрес комитета
ИМХО, явный недостаток того, что вошло в стандарт C++ -- это то, что короутины никак внешне не обозначаются именно как короутины. Если бы короутина особым образом помечалась в своей сигнатуре, например:
co_routine<task> bar() { ... // Что-то там. co_yield ...; ... } co_routine<task> foo() { ... co_await bar(); ... } |
То было бы проще ориентироваться в том, что является короутиной, а что нет.
Кстати говоря, не исключено, что в случае обязательного использования чего-то вроде co_routine<T> в сигнатуре короутины существенно бы упростилось определение того, является ли переданный куда-то callback короутиной или же обычным функциональным объектом. Простая диагностика по типу возвращаемого значения. А вот без co_routine<T> придется на концептах пилить проверку того, удовлетворяет ли некий тип T каким-то специфическим требованиям (скажем, наличие метода resume())...
Примеры в статьях не отличаются особой полнотой
Возможно, это у меня глаз замылился, то есть ощущение, что многие статьи на тему короутин вводят в рассмотрение что-то вроде:
task bar() { ... // Что-то там. co_yield ...; ... } task foo() { ... co_await bar(); ... } |
и затем концентрируются на рассмотрении того, как именно bar() вызывается из foo().
При этом сама foo является короутиной и просто так ее не вызвать. И мне лично очень не хватает примеров, которые бы показали что-то вроде:
task bar() { ... // Что-то там. co_yield ...; ... } task foo() { ... co_await bar(); ... } int main() { auto r = co_await foo(); ... // Что-то там для того, чтобы foo таки доработал. } |
И чтобы рассказ шел как бы "сверху вниз", т.е. с содержимого main(): вот, мол, здесь мы вызываем foo() посредством co_await, при этом происходит следующее (тут какие-то подробности) и у нас на руках оказывается объект (тут еще какие-то подробности) посредством которого мы можем возобновить короутину foo() и мы должны это сделать (потому-то и потому-то, а иначе то-то и то-то). После чего идет пояснение того, что происходит при входе в foo() и в какой момент возвращается объект в main() посредством которого мы возобновляем foo(). И т.д. в глубину.
Вот если бы именно полностью завершенных примеров с main() было больше, осознание того факта, что короутины -- это и bar(), и foo(), пришло бы ко мне гораздо быстрее.
Использование co_await для initial_suspend и final_suspend
Практически во всех статьях/докладах на тему короутин рассказывают и показывают на псевдопримерах кода во что раскрывается co_await когда внутри короутины вызывается co_await expr. И с этим особых проблем нет: определяется объект Awaiter, у него вызывается await_ready, затем await_suspend, затем await_resume.
У меня лично какой-то непонятный ступор наступает когда я пытаюсь осознать содержимое обертки, которую компилятор генерирует вокруг тела короутины, т.е. вот это вот:
... // Выделение места под фрейм короутины. auto promise = ...; // Создание объекта promise. co_await promise.initial_suspend(); try { ... // Само тело короутины. } catch(...) { promise.unhandled_exception(); } FinalSuspend: co_await promise.final_suspend(); |
Т.е. когда co_await разворачивается в некий набор действий с вызовом await_ready, await_suspend и await_resume внутри try..catch, то с этим все OK.
Но вот когда в рассмотрение добавляются еще и co_await для initial_suspend/final_suspend тут какой-то ступор начинается. Как будто стек переполняется. Фиг знает почему, но вот так оно у меня происходит.
Возможно, если бы мне попалось на глаза несколько примеров того, во что полностью раскрывается тело короутины (включая вот эти вот co_await на initial_suspend/final_suspend), то процесс вкуривания короутин шел бы пободрее.
Объект promise конструируется неявно
Сперва меня убивало то, что тип возвращаемой короутиной значения и тип того, что отдается в co_yield и co_return -- это разные вещи. И что в теле короутины нет привычного return, но самой короутиной какое-то значение возвращается (причем в непонятный для меня момент).
Затем, когда с этим аспектом у меня худо-бедно сложилось, тормозить начал на том, что объект promise создается неявно. И довольно хитрым способом.
Можно сказать, что на этом месте я все еще туплю. Как-то пока не уложилось в голове, что когда осуществляется первый вход в короутину, то за кулисами, невидимо для разработчика, создается promise и что этот promise остается жить до тех пор, пока живет короутина. Хотя просто так его не увидеть, получить к нему доступ можно, если правильно понимаю, только через coroutine_handle, который отдается в await_suspend для очередного Awaiter-а.
Время жизни короутины и валидность значения coroutine_handle
Белым пятном для меня пока что является время жизни фрейма короутины и, соответственно, валидность значения coroutine_handle для этой короутины.
Ибо, из того, что я прочитал, у меня отложились в голове две несколько противоречащие друг другу вещи.
Во-первых, coroutine_handle -- это не RAII объект. Для него явно и вручную нужно вызывать destroy(). Именно destroy() уничтожает фрейм короутины.
Во-вторых, если же исполнение доходит до конца короутины (т.е. не происходит приостановки короутины на co_await promise.final_suspend), то фрейм короутины разрушается автоматически, а значение coroutine_handle оказывается невалидным.
Т.е., получается что-то вроде: promise.final_suspend всегда должен приостанавливать текущую короутину и приостановленная короутина уничтожается явным вызовом coroutine_handle::destroy(). Но если promise.final_suspend короутину не приостанавливает, то короутина разрушается автоматически, в coroutine_handle оказывается мусор, а ручной вызов coroutine_handle::destroy() в этом случае ведет к UB.
Если я это все понят правильно, то по-моему, это жестяная жесть.
Вполне в духе C++, конечно. Но для новых фич, которые вводятся в язык на основании 35-летнего опыта, это выглядит как-то издевательстки жестко. С особым цинизмом, я бы сказал.
Однако, не уверен, что все понял правильно.
Список оказавшихся полезными для меня ссылок
Вот материалы, которые я штудирую снова и снова. С каждым новым прочтением в голове формируются все более отчетливые фрагменты. Общей картины пока нет, но и я не останавливаюсь.
Серия постов от Lewiss Baker: Coroutine Theory, C++ Coroutines: Understanding operator co_await, C++ Coroutines: Understanding the promise type, C++ Coroutines: Understanding Symmetric Transfer.
Большой пост от David Mazières: My tutorial and take on C++20 coroutines.
Три поста от Dawid Pilarski: Coroutines Introduction, Your First Coroutine, co_awaiting Coroutines.
Exploring MSVC Coroutine -- но здесь обсуждаются какие-то совсем старые предложения, хотя разбор того, во что все это разворачивается, мне оказался полезен.
Так-то статей разного объема и качества много. Но вот вышеперечисленные пока что выглядят наиболее толковыми и информативными.
Комментариев нет:
Отправить комментарий