четверг, 29 июля 2021 г.

[prog.c++] Что в механизме поддержки короутин в C++20 вызывает у меня сложности с освоением

Сегодня попробую рассказать о тех моментах в механизме поддержки короутин 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 -- но здесь обсуждаются какие-то совсем старые предложения, хотя разбор того, во что все это разворачивается, мне оказался полезен.

Так-то статей разного объема и качества много. Но вот вышеперечисленные пока что выглядят наиболее толковыми и информативными.

Комментариев нет: