Ну и зачем этот вход акторам C, D и E? Более того, откуда этот вход будет появляться во run-time, если, скажем, ссылку на B актор C получает в run-time, отправляет актору B одно-единственное сообщение и выбрасывает данную ссылку как ненужную?
Собственно проблема в том, что при описании dataflow и workflow сетей (а по большому счету, это одно и то же) узлы этих сетей обычно называют не акторами, а как-нибудь по другому
Ну да. Там будет про dataflow/workflow. Но не про «традиционную» модель акторов от Хьюита.
хотя они именно акторы и есть
Или это вам лично так кажется.
Чтобы было понятно: есть уже давно ставшим общеупотребительным термин «модель акторов», под которым более-менее понятно, что понимается. Вы, как приверженец dataflow-модели, можете считать, что модель акторов есть частный случай dataflow-модели. Однако вряд ли ваш взгляд на вещи найдет широкое понимание. У меня, например, не находит.
Dataflow актор защитить от перегрузки легко — стоит лишь добавить обратную связь по переполнению и завести ее на добавочный вход.
Попутно вопрос: допустим, есть актор B, на которого сыпятся сообщения от акторов C, D, E и далее по списку. Общий темп отсылки сообщений актору B превышает способности B по обработке входящих сообщений. Этот самый «добавочный вход» кому нужно завести и куда?
Мне казалось, что «модель акторов» (она же Actor Model на английском) — это именно то, что было озвучено сперва Хьюитом, а потом подхвачено Клингером и Агха. Об этой модели акторов и идет речь.
Где можно прочитать про «модель акторов» с другими корнями?
Здесь не соглашусь, так как именно эти свойства являются в модели Акторов основопологающими.
Вы же сами ниже описываете, что является основополагающим:
— создавать новые акторы
— посылать сообщения
— устанавливать, как следует реагировать на последующие полученные сообщения
Вот это и есть основополагающие свойства модели акторов. А свойства из Reactive Manifesto — это свойства реактивных систем, которые можно создавать и без использования модели акторов.
И многие системы применяют модель Акторов, чтобы достичь масштабируемости.
Это говорит лишь о том, что модель акторов применима и имеет некоторые преимущества в таких задачах. Но вовсе не о том, что responsive+resilient+elastic+message-driven являются свойствами модели акторов.
В Erlang Actor-ы называются process, но при этом они не перестают быть Акторами.
Сейчас Erlang считается одной из основных реализаций модели акторов. Но ирония в том, что описывая историю создания Erlang-а Джо Армстронг не говорил о том, что он использовал модель акторов в разработке Erlang-а (хотя этой модели к моменту начала работ над Erlang-ом уже было 13 лет), Erlang создавался под влиянием Prolog-а и Smalltalk-а. Так что авторы Erlang-а просто переизобрели модель акторов. И создали систему супервизоров, которой в модели акторов нет.
Зря вы привязали четыре свойства реактивных систем (responsive, resilient, elastic и message-driven) к свойствам модели акторов. Их старательно увязывают вместе продавцы услуг из Lightbend-а, которые, по совместительству, являются и разработчиками Akka, и соучастниками написания этого самого Reactive Manifesto. На самом же деле Actor Model вовсе не гарантирует, что разработанные на ее базе приложения будут иметь более-менее нормальную отзывчивость и/или масштабируемость. И, обратно, responsive+resilien+elastic+message-driven системы могут быть сделаны и без использования модели акторов.
По поводу Actors vs Microservices. Модель акторов вовсе не обязывает деплоить один и тот же инстанс приложения на все ноды. Запросто можно разрабатывать приложения на акторах так, что в одном компоненте приложения работают акторы типа X и Y, а в другом компоненте — акторы типов Z, V и W. При этом компоненты могут деплоится на разные ноды. И как раз то, что общение между акторами построено на базе асинхронного обмена сообщениями, а акторы не имеют разделяемого состояния, позволяет делать это безболезненно.
Так что Actors и Microservices в вашей терминологии соотносятся так же, как Microservices и SOA: microservices могут быть частью SOA, а акторы могут быть частью реализации микросервиса.
Ну и по поводу существующих реализаций модели акторов: имя им легион (языки программирования: https://en.wikipedia.org/wiki/Actor_model#Later_Actor_programming_languages, фреймворки для универсальных языков: https://en.wikipedia.org/wiki/Actor_model#Actor_libraries_and_frameworks). Сам же Erlang создавался без оглядки на Actor Model (автор Erlang-а описывая историю разработки языка не говорил, что использовал модель акторов).
Значит всё-таки решение задачи ложных или повторных срабатываний таймера остаётся разработчику.
Я бы не сказал, что это «ложные и повторные». Это скорее очередная гримаса многопоточности, особенно во времена реальных многоядерных машин. Ведь два потока действительно могут работать параллельно и независимо, от чего взаимные сочетания «кто кого обогнал, кто от кого отстал» могут принимать самые причудливые формы.
Я просто считал, что [T-d1, T+d2] входит в документированную погрешность, которую нужно учитывать. Разве что, думал, что по таймерам есть гарантия «сработает НЕ РАНЬШЕ заданного времени»(т.е. доставка будет только в момент >=T).
Это, кстати, хороший момент. Тут зависит от таймерного механизма. Если нет округления времени срабатывания (например, в механизмах timer_heap и timer_list), то таймер срабатывает только в момент >=T. А вот как для timer_wheel, где T должно попадать в «окно»… Наверное, тоже >=T, но навскидку не вспомню. Кроме того, пользователь может и свой таймерный механизм подсунуть, что у него будет — хз…
Но тут другое важно. Допустим, что агент A решает отменить заявку в (T-d1), он пытается вызвать timer_.release() и тут его нить вытесняется операционкой. Проходит немного времени и наступает момент T, нить таймера выставляет заявку. Тут просыпается нить агента A и выполнение timer_.release() продолжается. Но заявка уже в очереди. Вероятность этого невысока, но, как ни странно, на больших нагрузках она регулярно трансформируется в реальность.
Но тут вот в чем проблема, если таймер должен сработать в момент T, то на самом деле отложенное сообщение может стать в очередь получателя в диапазоне [T-d1, T+d2], где d1 и d2 — это очень маленькие величины, но, к сожалению не нулевые.
Предположим, что отложенное сообщение встает в очередь в момент времени (T-20us), а агент в момент времени (T-15us) отменяет таймер. Реальной отмены не будет, т.к. сообщение уже в очереди получателя.
И тут есть всего два надежных способа:
1. Самый простой. Передавать в отложенном сообщении некий прикладной ID. Как правило, в каждой задаче этот ID разный. Где-то строковый идентификатор, где-то указатель.
2. Создать отдельный mbox, подписаться на сообщение из него. Отослать отложенное сообщение в этот mbox. Затем, при отмене, нужно и отменить отложенное сообщение, и снять подписку с этого mbox-а. В таком случае даже если сообщение уже в очереди получателя, то оно будет проигнорировано, т.к. подписки на него уже нет.
Второй способ достаточно накладный. Хотя вот реализация time_limit для state_t сейчас работает именно по такому принципу.
Ну или моя задача не особо на них ложится и я пытаюсь притянуть ее за уши.
Ну или же ее можно решить на КА, но за счет дополнительных сообщений и, возможно, еще одного состояния.
Например: 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 (пример здесь).
И тут бы идеально подошел 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_;
};
Угу. Поэтому в первой реализации поддержки иерархических конечных автоматов и обработчиков on_enter/on_exit мы пошли по пути жестких ограничений. Тогда и реализация оказывается более простой, и поведение более предсказуемым и понятным.
По мере накопления опыта и рассмотрения сценариев от разных пользователей можно будет подумать о том, как эти ограничения смягчить.
Просто логично было бы предположить, что SO будет действовать согласно so_exception_reaction, а не просто вызовет std::terminate.
Не так все просто, к сожалению. Пользователь может выставить реакцию ignore_exception. Но т.к. смена состояния не была нормально завершена, то агент оказывается в непонятном (и, скорее всего, некорректном) состоянии.
Пользователь может получить полный контроль за действиями при смене состояния, если он делает их сам вне on_enter/on_exit. Но вот внутри on_enter/on_exit возможности сильно ограничиваются.
Сейчас я уже нашел более-менее подходящее решение — вместо перехода в статус st_wait_command вызывать отдельный метод, который решит нужно ли менять состояние или же начать выполнять новую команду. Это работает, но эстетически смущает.
Если я правильно понял ситуацию, то это самый простой и надежный способ. Я бы и сам так делал.
Выходит, если нельзя использовать внутри 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 и рестартовать приложение, чем пытаться выбраться из такой ситуации.
Как мне кажется, за зацикливанием должен следить все таки пользователь библиотеки.
В условиях иерархических конечных автоматов и, особенно, наследования, следить может быть слишком сложно. Т.е. вы написали класс агента A с несколькими состояниями, затем ваш коллега отнаследовался от A и ввел еще несколько состояний. Ваш коллега вообще может не знать, что в каком-то своем on_enter/on_exit вы делаете еще одну смену состояния. Так же как и вы не можете знать, что будет происходить в on_enter/on_exit у наследников вашего класса.
А при таких раскладах приходится слать дополнительное, совершенно не нужное, сообщение для смены состояния.
А расскажите, пожалуйста, про ваш случай подробнее. Может мы и правда слишком жестко к ограничениям относимся.
Кстати, на счет noexept, so_5::send ведь не noexept?
Было бы не плохо этот момент хотя бы немного осветить в этой статье.
Да, наверное, вы правы. Какие-то вещи представляются очевидными мне самому, но они не обязаны быть таковыми для всех остальных.
Если разрешить переходы в другое состояние в on_enter/on_exit, то легко дойти до ситуаций, когда возникнет зацикливание. И тогда следов проблемы не найдешь.
Еще один не самый очевидный момент: действия в on_enter/on_exit должны быть noexcept. Поскольку если при смене состояния (особенно если эта смена происходит в сложном иерархическом автомате) возникает исключение, то откатить все к исходной точке и обеспечить strong exception guarantee, скорее всего не получится.
Если хоть кому-то нравится, то значит не напрасно все было. Мы, кстати, открыты: есть интересно о чем-то еще узнать, то скажите о чем, постараемся выбрать время и рассказать.
Итого, выходит что разница между обычным агентом и stage-агентам только во времени жизни.
На самом деле не только. Разница в сроке жизни это уже следствие. Принципиальный момент в том, что stage-агент должен уметь обрабатывать операции сразу для N транзакций. Это усложняет его реализацию.
Ведь если email_body_checker проверяет только одно тело сообщения, то его реализация будет достаточно простой. А вот если он может _одновременно_ проверять сразу несколько сообщений, то это уже совсем другое дело.
Хотя email_body_checker — это не самый хороший здесь пример. Можно взять пример агента, который что-то с СУБД делает. Например, пусть в каждом email-е есть уникальный message-id и нам нужно сохранять эти message-id в БД для истории. Если агент message_id_saver рассчитан только на один email, то у него все просто: ему дали message-id, он получил коннект к БД, выполнил сохранение, отчитался о выполнении.
Но если мы будем создавать сразу 100500 таких простых message_id_saver, то наша работа не будет хорошо масштабироваться. Т.к. все эти агенты будут конкурировать за доступ к БД. И выполнять сохранение данных в БД построчно, что так же не эффективно.
А вот если мы сделаем stage-агента mass_message_id_saver, который может принять кучу message-id и все их вставить в БД одной bulk-операцией, то масштабирование у нас будет лучше. Но сама логика mass_message_id_saver станет сложнее, т.к. он должен быть накапливать у себя группу message-id, затем делать bulk-операцию, затем должен уметь обрабатывать негативные результаты buld-операции (например, что делать если из M message-id один все-таки оказался неуникальным?), должен уметь раздавать результаты разным агентам-инициаторам и т.д. и т.п.
В итоге получается, что каждый stage-агент оказывается заметно сложнее, чем простые агенты вроде email_body_checker. Но приложение из stage-агентов собрать проще и следить за ним проще, чем в случае кучи простых мелких агентов.
Эдакий синглтон.
На самом деле для балансировки нагрузки можно сделать сразу несколько однотипных stage-агентов для одной и той же операции. Но это уже совсем другая история ;)
Ну да. Там будет про dataflow/workflow. Но не про «традиционную» модель акторов от Хьюита.
Или это вам лично так кажется.
Чтобы было понятно: есть уже давно ставшим общеупотребительным термин «модель акторов», под которым более-менее понятно, что понимается. Вы, как приверженец dataflow-модели, можете считать, что модель акторов есть частный случай dataflow-модели. Однако вряд ли ваш взгляд на вещи найдет широкое понимание. У меня, например, не находит.
Попутно вопрос: допустим, есть актор B, на которого сыпятся сообщения от акторов C, D, E и далее по списку. Общий темп отсылки сообщений актору B превышает способности B по обработке входящих сообщений. Этот самый «добавочный вход» кому нужно завести и куда?
Где можно прочитать про «модель акторов» с другими корнями?
Вы же сами ниже описываете, что является основополагающим:
Вот это и есть основополагающие свойства модели акторов. А свойства из Reactive Manifesto — это свойства реактивных систем, которые можно создавать и без использования модели акторов.
Это говорит лишь о том, что модель акторов применима и имеет некоторые преимущества в таких задачах. Но вовсе не о том, что responsive+resilient+elastic+message-driven являются свойствами модели акторов.
Сейчас Erlang считается одной из основных реализаций модели акторов. Но ирония в том, что описывая историю создания Erlang-а Джо Армстронг не говорил о том, что он использовал модель акторов в разработке Erlang-а (хотя этой модели к моменту начала работ над Erlang-ом уже было 13 лет), Erlang создавался под влиянием Prolog-а и Smalltalk-а. Так что авторы Erlang-а просто переизобрели модель акторов. И создали систему супервизоров, которой в модели акторов нет.
По поводу Actors vs Microservices. Модель акторов вовсе не обязывает деплоить один и тот же инстанс приложения на все ноды. Запросто можно разрабатывать приложения на акторах так, что в одном компоненте приложения работают акторы типа X и Y, а в другом компоненте — акторы типов Z, V и W. При этом компоненты могут деплоится на разные ноды. И как раз то, что общение между акторами построено на базе асинхронного обмена сообщениями, а акторы не имеют разделяемого состояния, позволяет делать это безболезненно.
Так что Actors и Microservices в вашей терминологии соотносятся так же, как Microservices и SOA: microservices могут быть частью SOA, а акторы могут быть частью реализации микросервиса.
Ну и по поводу существующих реализаций модели акторов: имя им легион (языки программирования: https://en.wikipedia.org/wiki/Actor_model#Later_Actor_programming_languages, фреймворки для универсальных языков: https://en.wikipedia.org/wiki/Actor_model#Actor_libraries_and_frameworks). Сам же Erlang создавался без оглядки на Actor Model (автор Erlang-а описывая историю разработки языка не говорил, что использовал модель акторов).
Но тут другое важно. Допустим, что агент A решает отменить заявку в (T-d1), он пытается вызвать timer_.release() и тут его нить вытесняется операционкой. Проходит немного времени и наступает момент T, нить таймера выставляет заявку. Тут просыпается нить агента A и выполнение timer_.release() продолжается. Но заявка уже в очереди. Вероятность этого невысока, но, как ни странно, на больших нагрузках она регулярно трансформируется в реальность.
Отменить таймер можно и сейчас. Например:
Но тут вот в чем проблема, если таймер должен сработать в момент T, то на самом деле отложенное сообщение может стать в очередь получателя в диапазоне [T-d1, T+d2], где d1 и d2 — это очень маленькие величины, но, к сожалению не нулевые.
Предположим, что отложенное сообщение встает в очередь в момент времени (T-20us), а агент в момент времени (T-15us) отменяет таймер. Реальной отмены не будет, т.к. сообщение уже в очереди получателя.
И тут есть всего два надежных способа:
1. Самый простой. Передавать в отложенном сообщении некий прикладной ID. Как правило, в каждой задаче этот ID разный. Где-то строковый идентификатор, где-то указатель.
2. Создать отдельный mbox, подписаться на сообщение из него. Отослать отложенное сообщение в этот mbox. Затем, при отмене, нужно и отменить отложенное сообщение, и снять подписку с этого mbox-а. В таком случае даже если сообщение уже в очереди получателя, то оно будет проигнорировано, т.к. подписки на него уже нет.
Второй способ достаточно накладный. Хотя вот реализация time_limit для state_t сейчас работает именно по такому принципу.
Например:
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 (пример здесь).
У вас же, при использовании 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). Например, у вас может быть что-то вроде:
Вот в этом случае у вас отложенные сигналы от предыдущих операций будут восприниматься как тайм-ауты для текущей операции. Самый надежный способ избежать этого на данный момент — это включать в отложенное сообщение какой-то ID текущей операции. Например:
По мере накопления опыта и рассмотрения сценариев от разных пользователей можно будет подумать о том, как эти ограничения смягчить.
Не так все просто, к сожалению. Пользователь может выставить реакцию ignore_exception. Но т.к. смена состояния не была нормально завершена, то агент оказывается в непонятном (и, скорее всего, некорректном) состоянии.
Пользователь может получить полный контроль за действиями при смене состояния, если он делает их сам вне on_enter/on_exit. Но вот внутри on_enter/on_exit возможности сильно ограничиваются.
Если я правильно понял ситуацию, то это самый простой и надежный способ. Я бы и сам так делал.
Методы on_enter/on_exit лучше всего рассматривать как некоторые аналоги конструкторов и деструкторов, поскольку у них:
Поэтому если есть необходимость запихнуть в on_enter/on_exit какие-то сложные действия с высокой вероятностью возникновении ошибки, то нужно обрамлять эти действия блоком try/catch и самостоятельно ошибку устранять.
Но тут возникает другой момент: допустим, в on_enter вам нужно сделать действия A, B и C, и на действии C у вас возникает ошибка. Что делать в этом случае? Вы не может остаться в новом состоянии, т.к. из подготовительных действий A, B и C вы сделали только A и B. Но и просто откатить A и B так же недостаточно, т.к. вы не может просто так вернуться в свое исходное состояние.
В общем, куда не кинь, везде клин :(
Поэтому мы исходим из следующих соображений:
А расскажите, пожалуйста, про ваш случай подробнее. Может мы и правда слишком жестко к ограничениям относимся.
Не noexcept, send может бросать исключения.
Если разрешить переходы в другое состояние в on_enter/on_exit, то легко дойти до ситуаций, когда возникнет зацикливание. И тогда следов проблемы не найдешь.
Еще один не самый очевидный момент: действия в on_enter/on_exit должны быть noexcept. Поскольку если при смене состояния (особенно если эта смена происходит в сложном иерархическом автомате) возникает исключение, то откатить все к исходной точке и обеспечить strong exception guarantee, скорее всего не получится.
На самом деле не только. Разница в сроке жизни это уже следствие. Принципиальный момент в том, что stage-агент должен уметь обрабатывать операции сразу для N транзакций. Это усложняет его реализацию.
Ведь если email_body_checker проверяет только одно тело сообщения, то его реализация будет достаточно простой. А вот если он может _одновременно_ проверять сразу несколько сообщений, то это уже совсем другое дело.
Хотя email_body_checker — это не самый хороший здесь пример. Можно взять пример агента, который что-то с СУБД делает. Например, пусть в каждом email-е есть уникальный message-id и нам нужно сохранять эти message-id в БД для истории. Если агент message_id_saver рассчитан только на один email, то у него все просто: ему дали message-id, он получил коннект к БД, выполнил сохранение, отчитался о выполнении.
Но если мы будем создавать сразу 100500 таких простых message_id_saver, то наша работа не будет хорошо масштабироваться. Т.к. все эти агенты будут конкурировать за доступ к БД. И выполнять сохранение данных в БД построчно, что так же не эффективно.
А вот если мы сделаем stage-агента mass_message_id_saver, который может принять кучу message-id и все их вставить в БД одной bulk-операцией, то масштабирование у нас будет лучше. Но сама логика mass_message_id_saver станет сложнее, т.к. он должен быть накапливать у себя группу message-id, затем делать bulk-операцию, затем должен уметь обрабатывать негативные результаты buld-операции (например, что делать если из M message-id один все-таки оказался неуникальным?), должен уметь раздавать результаты разным агентам-инициаторам и т.д. и т.п.
В итоге получается, что каждый stage-агент оказывается заметно сложнее, чем простые агенты вроде email_body_checker. Но приложение из stage-агентов собрать проще и следить за ним проще, чем в случае кучи простых мелких агентов.
На самом деле для балансировки нагрузки можно сделать сразу несколько однотипных stage-агентов для одной и той же операции. Но это уже совсем другая история ;)