Познакомив читателей с фреймворком SObjectizer, его возможностями и особенностями, можно перейти к рассказу о некоторых уроках, которые нам довелось усвоить за более чем четырнадцать лет использования SObjectizer-а в разработке C++ного софта. Сегодня поговорим о том, когда агенты в виде конечных автоматов не являются хорошим выбором, а когда являются. О том, что возможность создания большого количества агентов – это не столько решение, сколько сама по себе проблема. И о том, как первое соотносится со вторым... Итак, в трех предыдущих статьях (раз, два и три) мы наблюдали за тем, как агент email_analyzer развивался от очень простого до более-менее сложного класса. Думаю, что у многих, кто посмотрел на финальный вариант email_analyzer-а, возник вопрос: «Но ведь это же очень сложно, неужели нельзя было проще?» Получилось так сложно потому, что агенты представляются в виде конечных автоматов. Для того чтобы обработать входящее сообщение, должен быть описан отдельный метод – обработчик события. Для того чтобы у агента мог запуститься новый обработчик события, текущий обработчик должен завершиться. Поэтому, чтобы отослать запрос и получить ответ агент должен завершить свой текущий обработчик, чтобы дать диспетчеру возможность вызвать соответствующий обработчик при поступлении ответа. Т.е. вместо: Приходится писать вот так: Отсюда и такой объем, и такая сложность у получившегося агента email_analyzer. Возможно, и в таком подходе найдутся какие-то ухищрения, которые бы сократили объем писанины на 20-30%, но принципиально ситуация не изменится. Вот что может существенно повлиять на понятность и компактность, так это уход от событийной модели на базе коллбэков в сторону линейного кода с синхронными операциями. Что-то вроде: Вот в таком случае получился бы более компактный и понятный код, который был бы похож на решение этой задачи в таких языках, как Erlang или Go. Наш опыт говорит о том, что в ситуациях, когда агент выполняет какой-то линейный набор операций вида «отослал запрос, тут же стал ждать единственный ответ», его реализация в виде конечного автомата будет невыгодной с точки зрения объема и сложности кода. Вместо простого ожидания ответа и непосредственного продолжения работы после его получения, агенту нужно завершать свой текущий обработчик события, а все остальные действия приходится выносить в другой обработчик. Если агент в течении своей жизни выполняет N последовательно идущих асинхронных операций, то у этого агента, скорее всего будет (N+1) обработчик. Что не есть хорошо, т.к. разработка и сопровождение такого агента будет отнимать много сил и времени. Совсем другая ситуация будет в случае, если в каждый момент, когда агент чего-то ждет, к нему может прийти несколько разных сообщений, и на каждое из них агент должен будет среагировать. Например, агент может ждать результата текущей операции, и в этот момент к агенту могут прийти запросы с проверкой статуса операции и с требованием выполнить новую операцию. В этом случае на каждом ожидании агента придется расписывать реакцию на все ожидаемые типы сообщений и это также довольно быстро может превратить код агента в объемную и плохо понимаемую лапшу. Поскольку SObjectizer на данный момент поддерживает агентов только в виде конечных автоматов, то нужно тщательно оценить, насколько хорошо логика прикладных агентов ложится на конечные автоматы. Если не очень хорошо, то SObjectizer может быть не лучшим выбором и имеет смысл посмотреть на решения, которые используют сопрограммы. Например, boost.fiber или Synca (о последнем были интересные статьи на Хабре: №1 и №2). Так что три предыдущие статьи в минисерии «SObjectizer: от простого к сложному», с одной стороны, показывают возможности SObjectizer-а, но, с другой стороны, позволяют увидеть, куда можно зайти, есть подойти к решению задачи не с той стороны. Например, если начать использовать агентов в виде конечных автоматов там, где имело бы смысл использовать агентов в виде сопрограмм. Но если для многих случаев сопрограммы выгоднее чем конечные автоматы, то почему SObjectizer не поддерживает агентов в виде сопрограммы? Тому есть несколько серьезных причин, как технических, так и организационных. Вероятно, если бы сопрограммы были частью языка C++, агенты-сопрограммы в SObjectizer уже были бы. Но т.к. сопрограммы в C++ сейчас доступны лишь посредством сторонних библиотек и тема это не самая простая, то мы не спешим с добавлением этой функциональности в SObjectizer. Тем более, что у этой проблемы есть совсем другая сторона. Но чтобы поговорить об этом, нужно зайти издалека... Давным давно, когда заработала первая версия SObjectizer-а, мы сами совершили ту же ошибку, что и многие новички, впервые получившие в руки инструмент на базе модели акторов: если есть возможность создавать агентов на каждый чих, значит нужно создавать. Выполнение любой задачки должно быть представлено в виде агента. Даже если эта задачка состоит в получении всего одного запроса и отсылки всего одного ответа. В общем, опьянение от новых возможностей из-за чего внезапно начинаешь придерживаться мнения, что «в мире нет ничего кроме агентов». Вылилось это в несколько негативных последствий. Во-первых, код приложений оказывался объемнее и сложнее, чем того хотелось бы. Ведь асинхронные сообщения подвержены потерям и там, где можно было бы написать один синхронный вызов, возникали навороты вокруг посылки сообщения-запроса, обработки сообщения-ответа, отсчета тайм-аута для диагностирования потери запроса или ответа. При анализе кода оказывалось, что где-то в половине случаев взаимодействие на сообщениях оправданы, т.к. там осуществлялась передача данных между разными рабочими потоками. А в оставшихся местах можно было слить кучу мелких агентов в один большой и выполнять все операции у него внутри через обычные синхронные вызовы функций. Во-вторых, оказалось, что за поведением построенного на агентах приложения гораздо сложнее следить и еще сложнее его предсказывать. Хорошей аналогией будет наблюдение за полетом большой стаи птиц: хотя правила поведения отдельной особи просты и понятны, предсказать поведение всей стаи практически невозможно. Так и в приложении, в котором одновременно живут десятки тысяч агентов: каждый из них работает вполне понятным образом, но совокупный эффект от их совместной работы может быть непредсказуем. Что еще плохо, так это увеличение объема информации, которая нужна для понимания происходящего в приложении. Возьмем наш пример с email_analyzer-ами. Один-единственный агент analyzer_manager может поставлять такие сведения, как общее количество ожидающих своей очереди запросов, общее количество живых агентов email_analyzer, минимальное, максимальное и среднее время ожидания запроса в очереди (аналогично и по временам обработки запросов). Поэтому контроль за деятельностью analyzer_manager не представляет из себя проблемы. А вот сбор, агрегация и обработка сведений от отдельных email_analyzer-ов – это уже сложнее. Причем, тем сложнее, чем больше этих агентов и чем короче их время жизни. Так что, чем меньше агентов живет в приложении, тем проще за ними следить, тем проще понимать, что и как происходит, тем проще предсказывать поведение приложения в тех или иных условиях. В-третьих, непредсказуемости, которые возникают время от времени в приложениях с десятками тысяч агентов внутри, могут приводить приложение в частично или полностью неработающее состояние. Характерный случай: в приложении под сотню тысяч агентов. Они все с помощью периодических сообщений контролируют тайм-ауты своих операций. И вот в один прекрасный момент наступают тайм-ауты сразу для, скажем, 20 тысяч агентов. Соответствующим образом на рабочих нитях распухают очереди сообщений для обработки. Эти очереди начинают разгребаться, каждый агент получает свое сообщение и обрабатывает его. Но пока эти 20 тысяч сообщений обрабатываются, проходит слишком много времени и от таймера прилетает еще 20 тысяч. Это в довесок к той части старых сообщений, которые все еще стоят в очередях. Понятное дело, что все обработаться не успевает и прилетает еще 20 тысяч сообщений. И т.д. Приложение вроде как честно пытается работать, но постепенно деградирует до полной неработоспособности. В результате хождения по этим граблям в самом начале использования SObjectizer в своих проектах мы пришли к выводу, что возможность создать миллион агентов – это в большей степени маркетинговый булшит, нежели востребованная в нашей практике* штука. И что подход, который стал известен под названием SEDA-way, позволяет строить приложения, которые намного проще контролировать и которые ведут себя намного предсказуемее. Суть использования SEDA-подхода в купе с моделью акторов в том, что вместо создания акторов, выполняющих целую цепочку последовательно идущих операций, лучше создать по одному актору на каждую операцию и выстроить их в конвейер. Для нашего примера с email-анализаторами, вместо того, чтобы делать агентов email_analyzer, последовательно выполняющих загрузку содержимого email-а, парсинг и анализ этого содержимого, мы могли бы сделать несколько stage-агентов. Один stage-агент контролировал бы очередь запросов. Следующий stage-агент обслуживал бы операции загрузки файлов с email-ами. Следующий stage-агент выполнял бы парсинг загруженного содержимого. Следующий – анализ. И т.д., и т.п. Принципиальный момент в том, что в показанных ранее реализациях email_analyzer сам инициирует все операции, но только для одного конкретного email-а. А в SEDA-подходе у нас было бы по одному агенту на каждую операцию, но каждый агент мог бы делать ее сразу для нескольких email-ов. Кстати говоря, следы этого SEDA-подхода видны даже в наших примерах в виде IO-агента, который есть ни что иное, как stage-агент из SEDA. И вот, когда мы стали активно использовать идеи из SEDA, то выяснилось, что stage-агенты вполне удобно реализуются в виде конечных автоматов, т.к. им в каждый конкретный момент времени приходится ожидать разных входящих воздействий и реагировать на них в зависимости от своего состояния. Тут, на наш взгляд, конечные автоматы в долгосрочной перспективе оказываются удобнее, чем сопрограммы. Кстати говоря, можно отметить еще один момент, на который часто обращают внимание те, кто впервые знакомится с SObjectizer-ом: многословность агентов. Действительно, как правило, агент в SObjectizer – это отдельный C++ класс, у которого, как минимум, будет конструктор, будут какие-то поля, которые должны быть проинициализированы в конструкторе, будет переопределен метод so_define_agent(), будет несколько обработчиков событий в виде отдельных методов… Понятное дело, что для простых случаев все это приводит к изрядному синтаксическому оверхеду (с). Например, в Just::Thread Pro простой актор-логгер может выглядеть так: Тогда как в SObjectizer, если пользоваться традиционным подходом к написанию агентов, потребуется сделать что-то вроде: Очевидно, что в SObjectizer писанины больше. Однако, парадокс в том, что если придерживаться SEDA-подхода, когда агентов не очень много, но они могут обрабатывать разные типы сообщений, код агентов довольно быстро распухает. Отчасти из-за самой логики работы агентов (как правило, более сложной), отчасти из-за того, что агенты наполняются дополнительными вещами, вроде логгирования и мониторинга. И тут-то и оказывается, что когда основной прикладной код агента имеет объем в несколько сотен строк, а то и больше, то размер синтаксического оверхеда со стороны SObjectizer-а совершенно незначителен. Более того, чем больше и сложнее агент, тем выгоднее оказывается его представление в виде отдельного C++ класса. На игрушечных примерах этого не видно, но в «боевом» коде ощущается довольно сильно (вот, скажем, маленький пример не самого сложного реального агента). Таким образом, на основании своего практического опыта мы пришли к выводу, что если должным образом сочетать модель акторов и SEDA-подход, то представление агентов в виде конечных автоматов – это вполне нормальное решение. Конечно, где-то такое решение будет проигрывать сопрограммам по выразительности. Но в целом, агенты в виде конечных автоматов работают более чем хорошо и особых проблем не создают. За исключением, разве что, сравнения различных подходов к реализации модели акторов на микропримерчиках. В завершении статьи хочется обратиться к читателям. У нас в планах есть еще одна статья, в которой мы хотим затронуть такую важную проблему механизма взаимодействия на основе асинхронных сообщений, как перегрузка агентов. И, заодно, показать, как SObjectizer реагирует на ошибки в агентах. Но интересно было бы узнать мнение аудитории: что вам понравилось, что не понравилось, о чем хотелось бы узнать больше. Это сильно поможет нам как в подготовке очередной статьи, так и в развитии самого SObjectizer-а. *Подчеркнем, что речь идет о нашем опыте. Очевидно, что другие команды, решающие с помощью модели акторов другие задачи, могут с успехом использовать большое количество акторов в своих приложениях. И имееть на этот счет прямо противоположное мнение. Велосипедостроитель, программист-камикадзе