Нижеследующий текст -- это результат раздумий на тему недавнего поста про потенциально возможный await в C++17. Если кому-то из читателей интересен небольшой поток сознания о том, в каком случае await с короутинами однозначно заруливает message-passing и finite-state machenes, милости прошу под кат.
Итак, допустим у нас есть код, который использует специальные механизмы языка+рантайма для запуска отдельных операций, в виде последовательности действий. Эти операции могут занимать какое-то время. Наша задача -- написать клеевой код, который будет выстраивать правильную последовательность вызова этих операций.
Если у нас есть ключевое слово await и соответствующая поддержка со стороны рантайма, мы можем написать что-то вроде:
void glue_proc() { while( true ) { await prepare_data(); await process_data(); await store_result(); } } |
В принципе, практически все то же самое можно изобразить и без await-а в предположении, что мы используем некую удобную библиотеку с готовым диспетчером короутин (файберов, легких потоков -- не суть важно).
void low_level_glue_proc( scheduler & sched ) { while( true ) { sched.await( [=]{ prepare_data( sched ); } ); sched.await( [=]{ process_data( sched ); } ); sched.await( [=]{ store_result( sched ); } ); } } |
За вызовом await или sched.await() будет скрываться довольно простая логика. Как я понимаю, нужно выполнить три действия: создать синхронизирующий объект (или пару вроде promise+future), запустить заказанное действие в виде короутины (файбера, легкого потока), передав ему ссылку на синхронизирующий объект, затем подождать пока синхронизирующий объект не сработает, позволив кому-то использовать текущий контекст исполнения. Т.е. внутри sched.await() может выглядеть как-то так:
template< class 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 ) { while( true ) { try{ sched.await( [=]{ prepare_data( sched ); } ); try { sched.await( [=]{ process_data( sched ); } ); sched.await( [=]{ store_result( sched ); } ); } catch( const std::exception & x ) { // State cannot be recovered. std::abort(); } } catch( const 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-подхода. Но, поскольку короутины -- это кооперативная многозадачность, то кроме особой осторожности при написании короутин, нужно иметь еще и продвинутый собственный диспетчер с набором необходимых для приложения примитивов. А это открывает новый простор для фантазий и рассуждений... О чем, пожалуй, стоит поговорить в другой раз.
Комментариев нет:
Отправить комментарий