Pull to refresh

Comments 23

Хм, довольно неожиданно, что из on_enter одного состояния нельзя перейти в другое состояние.
Было бы не плохо этот момент хотя бы немного осветить в этой статье.
Было бы не плохо этот момент хотя бы немного осветить в этой статье.
Да, наверное, вы правы. Какие-то вещи представляются очевидными мне самому, но они не обязаны быть таковыми для всех остальных.

Если разрешить переходы в другое состояние в on_enter/on_exit, то легко дойти до ситуаций, когда возникнет зацикливание. И тогда следов проблемы не найдешь.

Еще один не самый очевидный момент: действия в on_enter/on_exit должны быть noexcept. Поскольку если при смене состояния (особенно если эта смена происходит в сложном иерархическом автомате) возникает исключение, то откатить все к исходной точке и обеспечить strong exception guarantee, скорее всего не получится.
Как мне кажется, за зацикливанием должен следить все таки пользователь библиотеки. А то ведь при желании зацикливание можно и другими средствами организовать.

А при таких раскладах приходится слать дополнительное, совершенно не нужное, сообщение для смены состояния.

Кстати, на счет noexept, so_5::send ведь не noexept?
Как мне кажется, за зацикливанием должен следить все таки пользователь библиотеки.
В условиях иерархических конечных автоматов и, особенно, наследования, следить может быть слишком сложно. Т.е. вы написали класс агента A с несколькими состояниями, затем ваш коллега отнаследовался от A и ввел еще несколько состояний. Ваш коллега вообще может не знать, что в каком-то своем on_enter/on_exit вы делаете еще одну смену состояния. Так же как и вы не можете знать, что будет происходить в on_enter/on_exit у наследников вашего класса.
А при таких раскладах приходится слать дополнительное, совершенно не нужное, сообщение для смены состояния.
А расскажите, пожалуйста, про ваш случай подробнее. Может мы и правда слишком жестко к ограничениям относимся.
Кстати, на счет noexept, so_5::send ведь не noexept?
Не noexcept, send может бросать исключения.
А расскажите, пожалуйста, про ваш случай подробнее.

У меня есть некий агент, который может выполнять внешние команды. Выполнение команды связано с IO операциями и занимает время. Параллельно выполнять команды нельзя.

Агент принимает команды и складывает их в очередь. В числе прочих, агент имеет такие статусы как st_wait_command и st_wait_perform. Пока выполняется команда агент находится в состоянии st_wait_perform. После выполнения команды нужно начать выполнять следующую команду из очереди, если она не пуста. Или же вернутся в состояние st_wait_command.

И вот код по началу выполнения следующей команды было бы удобно разместить в on_enter состояния st_wait_comman. Тогда оставалось бы просто перейти в состояние st_wait_command после выполнения команды.

Сейчас я уже нашел более-менее подходящее решение — вместо перехода в статус st_wait_command вызывать отдельный метод, который решит нужно ли менять состояние или же начать выполнять новую команду. Это работает, но эстетически смущает. Ведь этот метод занимается как раз тем, чем должен бы заниматься on_enter.

Не noexcept, send может бросать исключения.

Выходит, если нельзя использовать внутри on_enter/on_exit без try/catch (как вы делаете у себя в примере).
Выходит, его нельзя использовать *
Я пока прокомментирую вот это:
Выходит, если нельзя использовать внутри on_enter/on_exit без try/catch (как вы делаете у себя в примере).

Методы on_enter/on_exit лучше всего рассматривать как некоторые аналоги конструкторов и деструкторов, поскольку у них:
  • во-первых, очень специфические задачи (on_enter похож на конструктор, т.к. позволяет задать какие-то начальные значения, а on_exit похож на деструктор, т.к. позволяет освободить ресурсы);
  • во-вторых, они выполняются в очень специфическом контексте, в котором возможности самого SObjectizer-а по преодолению возникающих проблем крайне ограничены. Очень похоже на ситуацию с выбросом исключения из деструктора объекта — как правило тут уж ничего кроме std::terminate не сделать.

Поэтому если есть необходимость запихнуть в on_enter/on_exit какие-то сложные действия с высокой вероятностью возникновении ошибки, то нужно обрамлять эти действия блоком try/catch и самостоятельно ошибку устранять.

Но тут возникает другой момент: допустим, в on_enter вам нужно сделать действия A, B и C, и на действии C у вас возникает ошибка. Что делать в этом случае? Вы не может остаться в новом состоянии, т.к. из подготовительных действий A, B и C вы сделали только A и B. Но и просто откатить A и B так же недостаточно, т.к. вы не может просто так вернуться в свое исходное состояние.

В общем, куда не кинь, везде клин :(

Поэтому мы исходим из следующих соображений:
  • когда при переходе из состояния в состояние нужно делать какие-то сложные цепочки действий с высокой вероятностью ошибок, то это следует делать не в on_enter/on_exit, а непосредственно в коде агента перед вызовом so_change_state;
  • на долю on_enter/on_exit остаются самые тривиальные действия, вроде назначения начальных значений. Если уж эти действия приводят к ошибкам, то лучше уж вызвать std::terminate и рестартовать приложение, чем пытаться выбраться из такой ситуации.
Да, это понятно, что возникают проблемы с исключениями при смене состояний.
Просто логично было бы предположить, что SO будет действовать согласно so_exception_reaction, а не просто вызовет std::terminate. Тот же самый рестарт агента может быть логичным поведением программы в подобных случаях.
Просто логично было бы предположить, что SO будет действовать согласно so_exception_reaction, а не просто вызовет std::terminate.

Не так все просто, к сожалению. Пользователь может выставить реакцию ignore_exception. Но т.к. смена состояния не была нормально завершена, то агент оказывается в непонятном (и, скорее всего, некорректном) состоянии.

Пользователь может получить полный контроль за действиями при смене состояния, если он делает их сам вне on_enter/on_exit. Но вот внутри on_enter/on_exit возможности сильно ограничиваются.
Ну да, ситуация не простая. Варианты, конечно, все еще есть. Но они, наверное, сделают ситуацию только еще более запутанной.
Угу. Поэтому в первой реализации поддержки иерархических конечных автоматов и обработчиков on_enter/on_exit мы пошли по пути жестких ограничений. Тогда и реализация оказывается более простой, и поведение более предсказуемым и понятным.

По мере накопления опыта и рассмотрения сценариев от разных пользователей можно будет подумать о том, как эти ограничения смягчить.
Хотя и если реакция зависит от состояния агента, то, вопрос, конечно становится несколько более сложным. Возможно, лучше использовать реакцию кооперации.
Сейчас я уже нашел более-менее подходящее решение — вместо перехода в статус st_wait_command вызывать отдельный метод, который решит нужно ли менять состояние или же начать выполнять новую команду. Это работает, но эстетически смущает.

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

Стало нужным добавить таймаут для команд. И тут бы идеально подошел time_limit для состояния st_wait_perform. Но так как после таймаута нужно начать выполнять следующую команду из очереди (если она есть), а соответственно и менять состояние обратно на st_wait_perform, то в методах on_enter/on_exit не выйдет обработать событие таймаута. Потому придется слать отложенное сообщение, судя по всему.
И тут бы идеально подошел time_limit для состояния st_wait_perform.
Ну вот тут я не уверен. time_limit хорош для безусловного перехода в другое состояние, когда не приходится при выходе анализировать, что успели сделать, что не успели.

У вас же, при использовании time_limit, потребуется проверять некоторый признак, пришел ли уже результат IO-операции или нет. Это дополнительный атрибут в агенте, дополнительная логика и т.д.

Если у вас агент входит в st_wait_perform и может в этом состоянии обрабатывать несколько IO-операций последовательно, то обычное отложенное сообщение выглядит более удобным решением, чем time_limit. При этом, однако, нужно не забыть вот о чем: если вы взвели отложенное сообщение M(1) для операции OP(1), потом операция OP(1) у вас успешно закончилась и вы успели начать операцию OP(2) (отправив M(2) для контроля тайм-аута OP(2)), то вы запросто можете получить M(1) и принять его за M(2). Например, у вас может быть что-то вроде:
class io_performer : public so_5::agent_t {
  struct io_timeout : public so_5::signal_t {};
  ...
  void on_next_operation(mhood_t<start_next_io>) {
    // Начинаем отсчет времени для очередной операции.
    so_5::send_delayed<io_timeout>(*this, ...);
    // Начинаем саму IO-операцию.
    perform_io(...);
  }
  void on_io_result(mhood_t<io_result> cmd) {
    ... // Должным образом обрабатываем результат.
    if(has_more_io_ops())
      so_5::send<start_next_io>(*this, ...);
  }
  void on_timeout(mhood_t<io_timeout>) {
     ... // Обрабатываем тайм-аут текущей операции.
  }
};
Вот в этом случае у вас отложенные сигналы от предыдущих операций будут восприниматься как тайм-ауты для текущей операции. Самый надежный способ избежать этого на данный момент — это включать в отложенное сообщение какой-то ID текущей операции. Например:
class io_performer : public so_5::agent_t {
  struct io_timeout : public so_5::message_t {
    op_id id_;
    io_timeout(op_id id) : id_(std::move(id)) {}
  };
  ...
  void on_next_operation(mhood_t<start_next_io>) {
    // Начинаем отсчет времени для очередной операции.
    current_id_ = create_current_op_id();
    so_5::send_delayed<io_timeout>(*this, ..., current_op_id_);
    // Начинаем саму IO-операцию.
    perform_io(...);
  }
  void on_io_result(mhood_t<io_result> cmd) {
    ... // Должным образом обрабатываем результат.
    current_op_id_ = null_id; // Сбрасываем ID, т.к. текущая операция завершилась.
    if(has_more_io_ops())
      so_5::send<start_next_io>(*this, ...);
  }
  void on_timeout(mhood_t<io_timeout> cmd) {
    if(current_op_id_ == cmd->id_) {
       ... // Обрабатываем тайм-аут текущей операции.
    }
  }
  ...
  op_id current_op_id_;
};
Да, ровно так сейчас и вышло. У операций уже до этого были ID, так что с этим проблем не возникло.

Видимо, я хочу от встроенного механизма конечных автоматов слишком многого. :) Ну или моя задача не особо на них (на конечные автоматы, в смысле) ложится и я пытаюсь притянуть ее за уши.
Ну или моя задача не особо на них ложится и я пытаюсь притянуть ее за уши.
Ну или же ее можно решить на КА, но за счет дополнительных сообщений и, возможно, еще одного состояния.

Например:
st_neutral:
..on_enter: отослать себе check_queue
..msg_check_queue -> если очередь пуста, то идем в st_wait_command, если не пуста, то идем в st_wait_perform;

st_wait_command:
..msg_command -> поставить заявку в очередь, перейти в st_wait_perform;

st_wait_perform:
..on_enter: инициировать первую операцию из очереди;
..msg_io_result: обработать результат, перейти в st_neutral;
..msg_command: поставить заявку в очередь;
..time_limit: перейти в st_io_timedout;

st_io_timeout:
..on_enter: обработать тайм-аут для текущей операции, отослать себе msg_check_queue;
..msg_check_queue: делегировать обработку msg_check_queue состоянию st_neutral;

Правда, не уверен, что эта логика оказывается проще и эффективнее.

Делегировать обработку сообщения из текущего состояния в другое состояние S можно посредством метода state_t::transfer_to_state (пример здесь).
Так может сделать встроенную возможность (на уровне SO) задавать таймерам идентификаторы (при формировании таймера)? И соответственно, возможность запустить, перезапустить или остановить таймер с указанным ID (в качестве ID выступает просто число, а разработчик сам решает как он их назначает и различает).
Тогда для данного случая, можно было бы не заботится о том, что такой таймер уже есть, т.к. при начале обработки очередного сообщения достаточно было бы «перезапустить» таймер с определённым ID. Если такого в очереди ещё не было, он появляется. Если уже есть, начинает отсчёт заново. Если перешли в состояние st_wait_perform, отключаем таймер.
А если он таки сработал (вызван обработчик), значит действительно «зависли в обработке».
С одной стороны, это снижает некоторую универсальность механизмов работы с сообщениями, т.к. выделяет отдельное понятие таймеры со своим API. С другой стороны, я так понял всё-равно для работы с таймерами, уже есть свой отдельный API.
К сожалению, я не понял идею такого API :(

Отменить таймер можно и сейчас. Например:
so_5::timer_id_t timer = so_5::send_periodic<msg>(*this,
  std::chrono::milliseconds(250), // Задержка.
  std::chrono::milliseconds::zero() // Нет повторения, однократная доставка.
);
...
timer.release(); // Отменили таймер.
Но тут вот в чем проблема, если таймер должен сработать в момент T, то на самом деле отложенное сообщение может стать в очередь получателя в диапазоне [T-d1, T+d2], где d1 и d2 — это очень маленькие величины, но, к сожалению не нулевые.

Предположим, что отложенное сообщение встает в очередь в момент времени (T-20us), а агент в момент времени (T-15us) отменяет таймер. Реальной отмены не будет, т.к. сообщение уже в очереди получателя.

И тут есть всего два надежных способа:

1. Самый простой. Передавать в отложенном сообщении некий прикладной ID. Как правило, в каждой задаче этот ID разный. Где-то строковый идентификатор, где-то указатель.

2. Создать отдельный mbox, подписаться на сообщение из него. Отослать отложенное сообщение в этот mbox. Затем, при отмене, нужно и отменить отложенное сообщение, и снять подписку с этого mbox-а. В таком случае даже если сообщение уже в очереди получателя, то оно будет проигнорировано, т.к. подписки на него уже нет.

Второй способ достаточно накладный. Хотя вот реализация time_limit для state_t сейчас работает именно по такому принципу.
>> К сожалению, я не понял идею такого API :(
Ну, в целом Вы ответили, что всё уже реализовано )

Идея была добавить поддержку ID в самом API SO, чтобы каждому разработчику не приходилось это делать самостоятельно. Например как ещё один параметр (не обязательный)
so_5::timer_id_t timer = so_5::send_periodic<msg,TYPEID>(*this,
  std::chrono::milliseconds(250), 
  std::chrono::milliseconds::zero(),
  myID  // <-- здесь передаём свой ID, который потом придёт к нам в обработчике
);
...

А проблема неточности таймеров, это да. Надёжный способ, требует сложной реализации. Я просто считал, что [T-d1, T+d2] входит в документированную погрешность, которую нужно учитывать. Разве что, думал, что по таймерам есть гарантия «сработает НЕ РАНЬШЕ заданного времени»(т.е. доставка будет только в момент >=T). При этом, конечно остаётся проблема отмены таймера, когда сообщение уже в очереди, но не обработано. Но с другой стороны, если гарантировано помещение в очередь для >=T, то значит отмена таймера (в момент >=T) фактически происходит когда таймер уже сработал и честно ждёт обработки в очереди.

Я просто считал, что [T-d1, T+d2] входит в документированную погрешность, которую нужно учитывать. Разве что, думал, что по таймерам есть гарантия «сработает НЕ РАНЬШЕ заданного времени»(т.е. доставка будет только в момент >=T).
Это, кстати, хороший момент. Тут зависит от таймерного механизма. Если нет округления времени срабатывания (например, в механизмах timer_heap и timer_list), то таймер срабатывает только в момент >=T. А вот как для timer_wheel, где T должно попадать в «окно»… Наверное, тоже >=T, но навскидку не вспомню. Кроме того, пользователь может и свой таймерный механизм подсунуть, что у него будет — хз…

Но тут другое важно. Допустим, что агент A решает отменить заявку в (T-d1), он пытается вызвать timer_.release() и тут его нить вытесняется операционкой. Проходит немного времени и наступает момент T, нить таймера выставляет заявку. Тут просыпается нить агента A и выполнение timer_.release() продолжается. Но заявка уже в очереди. Вероятность этого невысока, но, как ни странно, на больших нагрузках она регулярно трансформируется в реальность.
Да. Согласен. Значит всё-таки решение задачи ложных или повторных срабатываний таймера остаётся разработчику. Что в целом тоже нормально…
Надёжный код, должен подразумевать обработку и таких ситуаций.

P.S. Даже std::condition_variable могут ложно просыпаться, чего уж… :)
Значит всё-таки решение задачи ложных или повторных срабатываний таймера остаётся разработчику.
Я бы не сказал, что это «ложные и повторные». Это скорее очередная гримаса многопоточности, особенно во времена реальных многоядерных машин. Ведь два потока действительно могут работать параллельно и независимо, от чего взаимные сочетания «кто кого обогнал, кто от кого отстал» могут принимать самые причудливые формы.
Sign up to leave a comment.

Articles