среда, 14 января 2015 г.

[prog.c++.thoughts] У короутин и механизма await-а есть таки уникальные достоинства

Нижеследующий текст -- это результат раздумий на тему недавнего поста про потенциально возможный await в C++17. Если кому-то из читателей интересен небольшой поток сознания о том, в каком случае await с короутинами однозначно заруливает message-passing и finite-state machenes, милости прошу под кат.

Итак, допустим у нас есть код, который использует специальные механизмы языка+рантайма для запуска отдельных операций, в виде последовательности действий. Эти операции могут занимать какое-то время. Наша задача -- написать клеевой код, который будет выстраивать правильную последовательность вызова этих операций.

Если у нас есть ключевое слово await и соответствующая поддержка со стороны рантайма, мы можем написать что-то вроде:

void glue_proc()
{
   whiletrue )
   {
      await prepare_data();
      await process_data();
      await store_result();
   }
}

В принципе, практически все то же самое можно изобразить и без await-а в предположении, что мы используем некую удобную библиотеку с готовым диспетчером короутин (файберов, легких потоков -- не суть важно).

void low_level_glue_proc( scheduler & sched )
{
   whiletrue )
   {
      sched.await( [=]{ prepare_data( sched ); } );
      sched.await( [=]{ process_data( sched ); } );
      sched.await( [=]{ store_result( sched ); } );
   }
}

За вызовом await или sched.await() будет скрываться довольно простая логика. Как я понимаю, нужно выполнить три действия: создать синхронизирующий объект (или пару вроде promise+future), запустить заказанное действие в виде короутины (файбера, легкого потока), передав ему ссылку на синхронизирующий объект, затем подождать пока синхронизирующий объект не сработает, позволив кому-то использовать текущий контекст исполнения. Т.е. внутри sched.await() может выглядеть как-то так:

templateclass R >
R scheduler::await( function< R() > action )
{
   scheduler::promise< R > promise;
   this->launch_coroutine( [=]{ promise.set_result( action() ); } );
   this->wait_for_completion( promise.future() );

   return promise.future().get();
}

Где за вызовом sched.wait_for_completion() будет скрываться сразу два действия: во-первых, предоставление возможности диспетчеру отдать контекст исполнения какой-то из готовых к исполнению короутин (файберов, легких потоков) и, во-вторых, сохранение у себя информации о текущей короутине (файбере, легком потоке), который нужно будет "разбудить" как только конкретный promise/future будет выставлен.

Собственно, вот так я себе представляю механизм работы с короутинами и вещами вроде await. Если ошибаюсь, то буду признателен за комментарии с исправлениями/дополнениями.

После того, как в голове сложилось представление об этом механизме, захотелось придумать, как аналог этого "клеевого кода" можно было бы написать с использованием message-passing-а.

Тут-то выявилось первое преимущество асинхронного кода на основе await-а: очень простой механизм перехода к следующему шагу в "клеевом коде" при завершении очередной операции. Т.е., от прикладного программиста вообще ничего не требуется, используемый им инструмент сам поймет, что prepare_data() завершил свою работу. Тогда как в message-passing-подходе требуется отправить инициирующее операцию сообщение, а затем получить сообщение-уведомление о том, что операция завершилась. Поэтому в очень простом варианте на обмене сообщениями этот "клеевой код" мог бы выглядеть как-то так (вариантов, на самом деле, много, это просто хороший для иллюстрации вариант):

void message_passing_glue_proc()
{
   handle< next_turn >( []{ send< prepare_data >(); } );
   handle< prepare_data_completed >( []{ send< process_data >(); } );
   handle< process_data_completed >( []{ send< store_data >(); } );
   handle< store_data_completed >( []{ send< next_turn >(); } );

   send< next_turn >();
}

Здесь сначала регистрируется несколько обработчиков для разных сообщений, которые могут ходить в системе. Затем цикл их обработки запускается путем отсылки первого сообщения.

Не смотря на то, что код на сообщениях крайне примитивный и довольно далекий от реальности, он уже выглядит слишком громоздким. В реальном коде пришлось бы писать больше, т.к. нужно было указывать, кому отсылаются сообщения, куда отсылаются ответы, как осуществлять переходы из состояния в состояние и игнорировать сообщения, которые в данном состоянии нас не интересуют. Если все это добавить, то код на основе message-passing-а должен еще вырасти в объеме.

Конечно, можно попробовать придумать какой-то синтаксический сахар, чтобы можно было увязывать цепочки действий, вроде чего-то подобного:

void message_passing_glue_with_syntax_sugar_proc()
{
   handle< next_turn >( []{
         action< prepare_data >() |
         action< process_data >() |
         action< store_data >() |
         action< next_turn >();
      } );
}

Где за каждым вызовом action скрывалось бы создание нового состояния, при входе в которое отсылается указанное сообщение, после чего ожидается получение уведомления об окончании обработки этого сообщения, после чего инициируется переход к состоянию, которое определяется следующим вызовом action. На первый взгляд, все это выглядит вполне себе реализуемым.

Но тут возникает следующая проблема.

А как быть с ошибками?

Т.е., что будет, если при обработке сообщения process_data возникнет ошибка? Или что будет, если сообщение process_data вообще до получателя не дойдет?

Кому и в каком виде информацию об этой ошибке доставлять? Как и где указывать обработчика для таких ошибок?

Тогда как в подходе с await-ом этот вопрос может решаться посредством выбрасывания исключения из await-а. И обработки этого исключения уже привычным для пользователя способом.

Так, даже если мы не имеем поддержки await-а на уровне языка, вполне можно написать вот такой код:

void low_level_glue_proc_with_exceptions( scheduler & sched )
{
   whiletrue )
   {
      try{
         sched.await( [=]{ prepare_data( sched ); } );

         try
         {
            sched.await( [=]{ process_data( sched ); } );
            sched.await( [=]{ store_result( sched ); } );
         }
         catchconst std::exception & x )
         {
            // State cannot be recovered.
            std::abort();
         }
      }
      catchconst std::exception & x )
      {
         // Just ignoring errors at that stage.
      }
   }
}

Т.е. все довольно привычно и предсказуемо для разработчика. В отличии от message-passing-подхода, где уведомление о проблеме обработчика может приходить разве что в виде еще одного сообщения. Т.е. вместо привычного try-catch придется делать что-то вроде:

void message_passing_glue_with_syntax_sugar_and_errors_proc()
{
   handle< next_turn >( []{
         action< prepare_data >().on_failure( []{ /* Just ignore */ } ) |
         action< process_data >().on_failure(
            []( const auto & x ) { std::abort(); } ) |
         action< store_data >().on_failure(
            []( const auto & x ) { std::abort(); } ) |
         action< next_turn >();
      } );
}

Так что да, в некоторых случаях код на короутинах и await-ах будет гораздо понятнее и удобнее для разработчика, чем код на основе message-passing-подхода. Но, поскольку короутины -- это кооперативная многозадачность, то кроме особой осторожности при написании короутин, нужно иметь еще и продвинутый собственный диспетчер с набором необходимых для приложения примитивов. А это открывает новый простор для фантазий и рассуждений... О чем, пожалуй, стоит поговорить в другой раз.

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