четверг, 18 февраля 2016 г.

[prog.c++] Так почему же создание и удаление агентов в SO-5 -- это дорогая операция?

Все бенчмарки, которые делались для SObjectizer-а, показывают, что процедура создания/удаления агентов в SO-5 является дорогой даже по сравнению с другими акторными фреймворками (вроде CAF или Akka), не говоря уже про специализированные языки (Erlang, Go). В данный посте попробуем объяснить, почему так происходит, почему это никогда нас, как активных пользователей SO-5, не беспокоило. Ну и обозначить некоторые возможности для улучшения данного показателя.

Итак, сначала покажем масштаб проблемы. Для этого возьмем такой недавно возникший забавно-бесполезный бенчмарк, как skynet 1M threads microbenchmark. Его суть в том, что нужно создать агента, который создаст еще 10 агентов, которые создадут еще 10 агентов и т.д. до тех пор, пока не будет создано 1M агентов. После чего каждый агент должен будет послать своему родителю свой порядковый номер от 0 до 999999. Родительский агент суммирует эти значения, а сумму отсылает своему родителю. Тот получает 10 таких значений, суммирует их и отсылает их своему родителю. И т.д., пока дело не дойдет до корневого агента.

Оставим за бортом разговор о том, насколько эта задача актуальна для модели акторов. Просто посмотрим на то, как она может быть решена честным образом посредством SObjectizer-а. Под "честным" подразумевается создание нужного количества агентов и отсечка времени работы теста только после того, как все ресурсы будут очищены. Плюс к тому, агенты будут распределены между несколькими рабочими потоками.

Полный код решения можно увидеть в репозитории. Этот сделанный "в лоб" вариант очень сильно проигрывает такому же сделанному "в лоб" варианту на go. Так, у меня на машине Go-шный вариант отрабатывает за 900ms, а SO-шный где-то за 5000ms. Откуда такой разрыв?

Тут есть несколько важных факторов.


Во-первых, агенты в SO-5 регистрируются не по одному, а в составе коопераций. Даже есть нужно зарегистрировать всего одного агента, то все равно создается кооперация с единственным агентом внутри. В этом принципиальное отличие SObjectizer от других инструментов на базе модели акторов.

Почему мы так сделали? Потому, что на практике нам чаще всего нужно было создавать именно группы агентов, совместно выполняющих какую-то задачу. Взять недавний пример с имитатором домофона: там в кооперацию имитатора входит сразу несколько агентов, каждый из которых делают свою часть работы, но требуемую функциональность они обеспечивают лишь все вместе.

А раз агенты, как правило, регистрируются и дерегистрируются группами, то имеет смысл упростить для пользователя этот процесс. Вместо того, чтобы программист сначала запускал первого агента группы, затем второго, затем третьего и т.д., можно дать ему возможность создать сразу всех агентов группы, а затем запустить их единовременно. Это гораздо проще, ведь тогда не нужно учитывать тонких нюансов, когда созданные в начале агенты не могут нормально работать, пока не будут созданы все остальные. Кроме того, намного проще обрабатывать ситуации, когда что-то пошло не так при создании (i+1) агента группы и возникла необходимость удалить ранее созданных агентов.

Итак, нам показалось разумным поддерживать понятие кооперации агентов.

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

Ну и раз имя должно быть уникальным, нужно вести список имен. А при операциях регистрации/дерегистрации кооперации проводить поиск в этом списке и, при необходимости, его модификацию. Это уже некоторые накладные расходы. Кроме того, этот список защищен mutex-ом, дабы при проведении операций регистрации/дерегистрации кооперации одновременно на разных нитях не запортить его. Соответственно, доступ к этому списку -- это еще дополнительные расходы. Особенно в ситуациях, когда сразу несколько нитей борются за mutex (часть из них тупо простаивает ожидая своей очереди). Что, как правило, и происходит в микробенчмарках вроде упомянутого выше skynet.

Кроме того, даже если бы существовали полностью безымянные кооперации, все равно в SObjectizer Environment пришлось бы хранить их список. Это необходимо для нормального выполнения процедуры завершения работы, при которой все кооперации должны быть сначала дерегистрированы. Для чего нужно пройти по общему списку и дать команды каждой кооперации на завершение работы.

Итак, чем больше рабочих нитей одновременно производят регистрацию/дерегистрацию коопераций, чем чаще эти операции происходят, чем больше коопераций существует в SObjectizer Environment, тем дольше выполняется каждая операция и тем дольше рабочие нити спят на общем mutex-е.


Во-вторых, каждый агент в SObjectizer, обрабатывает, как минимум, два сообщения. Первое сообщение отсылается агенту сразу после завершения регистрации. В результате его обработки у агента вызывается виртуальный метод so_evt_start. Второе сообщение отсылается агенту при дерегистрации. Во время его обработки у агента вызывается виртуальный метод so_evt_finish.

Даже если агент не переопределяет методы so_evt_start/so_evt_finish, эти невидимые для агента сообщения все равно ему доставляются и он их все равно обрабатывает.

А это ведет к тому, что если в каком-то микробенчмарке создается 50M агентов, каждый из которых обрабатывает всего одно прикладное сообщение, то на самом деле эти агенты обрабатывают не 50M сообщений, а 150M сообщений. Просто одно из них агент получает явным образом, а два других ему не видны.

Отменить сейчас обработку "невидимых" сообщений start и finish нельзя. В принципе, можно сделать какую-то опцию, выбрав которую агент не будет получать сообщение start (хотя это может выйти боком при наследовании агентов). А вот запретить обработку сообщения finish не представляется возможным, т.к. невидимый пользователю обработчик этого сообщения, помимо вызова so_evt_finish, еще и выполняют важные действия по завершению дерегистрации кооперации.

Вот такая особенность в SO-5: дабы предоставить агенту возможность среагировать на начало и на завершение работы внутри SObjectizer Environment, гарантировав при этом запуск so_evt_start/so_evt_finish на рабочем контексте агента, пришлось пойти на использование двух служебных сообщений. Пользователь их не видит, но связанные с ними накладные расходы имеют место быть.


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

Например, возьмем диспетчер active_obj. Он выделяет каждому привязанному к нему агенту отдельную рабочую нить. Соответственно, при регистрации агента рабочую нить нужно создать, а при дерегистрации агента -- удалить.

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

При регистрации кооперации из N агентов может оказаться, что для (К+1)-го агента ресурсов не хватает. Т.е. первые K агентов кооперации к диспетчерам привязаны и для них рабочие нити нашлись, а вот на (К+1)-ом наступил облом. Значит предыдущие действия нужно откатывать, а привязанных ранее агентов -- отвязывать.

При этом первым К агентам после успешной привязки к своим диспетчерам еще нельзя начинать работать. Т.е. нельзя отослать им стартовое сообщение, т.к. они могут успеть его получит и обработать еще до того, как мы столкнемся с проблемой на (K+1)-ом агенте. Поэтому привязка агентов к диспетчерам выполняется как двухфазная операция. На первой фазе выполняется преаллокация ресурсов диспетчера для очередного агента. И лишь если преаллокация прошла успешно кооперация считается зарегистрированной и агентам отсылается стартовое сообщение.

С дерегистрацией еще интереснее. Допустим, у нас есть кооперация из одного агента, привязанного к active_obj диспетчеру. Мы ее дерегистрируем, агент получает свое финальное сообщение, отрабатывает его so_evt_finish и тут мы понимаем, что теперь то уж точно все. Кооперация может быть уничтожена, а выделенная агенту нить -- завершена. Мы дергаем active_obj диспетчер дабы освободить нить, он выставляет специальный флаг, обнаружив который нить должна завершиться, и вызывает thread::join дабы дождаться ее завершения... И знаете что?

Из вызова thread::join возврата не будет. Поскольку мы вошли в join находясь на этой самой нити! Т.е. мы пытаемся дождаться завершения самих себя.

И такие фокусы возможны не только с active_obj-диспетчером. Поэтому отвязку агента от диспетчера нужно делать на отдельном контексте, который этому диспетчеру не принадлежит. Для этих целей в SObjectizer Environment есть отдельная нить, предназначенная специально для выполнения финальной стадии дерегистрации коопераций. Когда последний агент кооперации отрабатывает свое финальное сообщение, на эту нить выставляется отдельная заявка, при обработке которой агенты кооперации отвязываются от своих диспетчеров. Эдакая нить для сборки мусора, где мусором являются уже ставшие ненужными кооперации.

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


Ну и еще есть маленькое, в-четвертых. Которое непосредственно связано со всеми тремя вышеозвученными факторами.

Если мы регистрируем N агентов, которые привязаны к разным контекстам, то очень много хлопот может доставить тот факт, что агенты стартуют свою работу с разным темпом.

Например, может случиться так, что на второй фазе регистрации мы отослали стартовое сообщение первым K агентам. Эти агенты могут успеть обработать свои стартовые сообщения. И, более того, они могут успеть наотсылать кучу сообщений другим агентам своей кооперации, которым стартовое сообщение отослано еще не было.

Дабы не получилось так, что (K+1) агент успел получить прикладное сообщение от K-го агента еще до того, как к нему придет стартовое сообщение (и будет вызван so_evt_start), SObjectizer предпринимает некоторые действия. Он не позволяет запуститься so_evt_start для первых агентов кооперации до тех пор, пока стартовое сообщение не будет отослано всем остальным агентам. Это ведет к тому, что самые шустро стартовавшие агенты кооперации могут некоторое время сидеть без дела (фактически, заснув и притормозив свою рабочую нить). А это так же будет сказываться на показателях в отдельных микробенчмарках.

Ну и еще один малюсенький нюанс, который можно было бы выделить как "в-пятых", но проще объединить его с "в-четвертых". Дело в том, что раз агенты стартуют единой группой более-менее одномоментно, то выгодно, чтобы агенты уже были полностью готовы к работе. Т.е. подписаны на те сообщения из тех почтовых ящиков, которые агентов интересуют. Ибо, если делать подписку внутри so_evt_start, то можно наткнуться на ситуацию, когда K-ый агент отослал сообщение (K+1)-му агенту, а тот еще не успел на это сообщение подписаться.

Посему перед отсылкой агентам стартового сообщения для всех агентов кооперации вызывается метод so_define_agent(). А значит это дополнительные накладные расходы, которых нет в других фреймворках.


Итак, регистрация и дерегистрация агентов группами (т.е. кооперациями) сопряжено с некоторыми накладными расходами. Удельный вес этих накладных расходов тем выше, чем меньше полезной работы выполняют агенты. А это как раз и происходит в большинстве микробенчмарков. Взять тот же skynet. Там громадная доля агентов (фактически, миллион) нужна только для того, чтобы отослать одно единственное сообщение. Т.е. создается 100K коопераций с агентами, отсылающими всего по одному сообщению каждый. И обрабатывающими при этом по два сообщения (start+finish).

В тех прикладных задачах, в которых нам (и не только нам) доводилось применять SObjectizer, ситуация была сильно другой. Агентов было не миллион. И даже не сотня тысяч. Десятки, сотни, ну иногда тысячи. Живущие очень долго, месяцами буквально. Пропуская через себя миллиарды сообщений. В таких условиях накладные расходы на регистрацию группы агентов не идут ни в какое сравнение с удобством, которое получаешь от работы с кооперациями.

Поэтому с точки зрения использования SO-5 в реальной работе стоимость операций регистрации/дерегистрации практически не важна. Т.к. создание большого количества короткоживущих агентов -- это не есть хорошая идея в большинстве случаев (по самым разным причинам, но это тема для отдельного разговора, частично она затронута здесь).


Однако, т.к. хорошие показатели в левых бенчмарках -- это способ привлечения внимания и своего рода стимул для новичков попробовать инструмент, то имеет смысл подумать о том, что и как можно подкрутить, дабы улучшить показатели SObjectizer в операциях создания/удаления агентов.

В первую очередь имеет смысл пройтись еще раз по текущим реализациям процедур регистрации/дерегистрации. В последний раз узкие места исследовались в этой части SObjectizer-а довольно давно. Может быть, если посмотреть еще раз свежим взглядом, некоторые внутренние резервы будут найдены.

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

Но, думается, все это принесет выигрыш, измеряемый процентами или десятками процентов в лучшем случае. Кардинальное изменение ситуации, полагаю, возможно, если предоставить пользователям возможность создавать легковесные сущности вместо полноценных агентов. Что-то типа единичных task-ов, привязываемых сразу к диспетчеру.

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

Вот тогда с языками вроде Go или Erlang в некоторых типах бенчмарков вполне можно будет конкурировать. Вот, скажем, решение для skynet на одном единственном mchain-е, сообщения из которого обрабатываются на главной нити приложения. Работает с такой же скоростью, как и программа на Go.

Только вот нужны ли такие task-и в SObjectizer?

Не понятно. С одной стороны, не есть хорошо смешивать в одном флаконе агентов еще с чем либо. С другой стороны, SO-5 и так сейчас базируется не только на Actor Model, но и на Publish-Subscribe, и на CSP-каналах. Так что и лековесным task-ам вполне может найтись место. Особенно, если кого-то эта фича заинтересует хотя бы чисто теоретически :)

Отправить комментарий