суббота, 30 мая 2020 г.

[prog.c++] Насколько дорого дергать раз в секунду некий метод у целой кучи объектов?

Нужно спроектировать решение задачи, в которой есть N рабочих нитей и на каждой из них нужно обрабатывать тысячи параллельных активностей. В том числе у этих активностей нужно контролировать итечение тех или иных тайм-аутов.

Скорее всего каждая активность будет реализована неким объектом (внутри которого будет какой-то конечный автомат, а снаружи каждый объект будет иметь некий общий интерфейс).

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

Смущало то, что раз в секунду придется дергать метод у тысяч объектов. С перспективой делать это для десятков тысяч объектов. Соответственно, прежде чем принимать такое проектное решение, захотелось узнать, а какова будет примерная стоимость подобного способа.

Для оценки была сделана небольшая программа, в которой создается несколько рабочих нитей, на каждой из которых создается множество объектов handler-ов, а затем с темпом ~1s у этих handler-ов вызывается метод on_next_turn.

Реализация on_next_turn у handler-ов не совсем пустая. А что-то типа вот такого:

void
actual_handler_one_t::on_next_turn( trigger_t & trigger )
{
   const auto now = std::chrono::steady_clock::now();
   if( now > m_border )
   {
      trigger.triggered();
      m_border = now + m_step;
   }
}

Т.е. в каждом вызове on_next_turn вызывается еще и метод now из steady_clock.

Так же в этом тесте я постарался сделать так, чтобы компилятор не повыбрасывал код, который компилятору мог бы показаться неимеющим наблюдаемых эффектов. Поэтому интерфейсы и реализации объектов handler-ов были разнесены по разным файлам, сделана пара разных реализаций handler-ов (в каждой из которых дергается steady_clock::now()), введен абстрактный тип trigger_t, у которого handler-ы время от времени вызывают triggered. Что позволяет думать, что проведенный замер таки показывает именно то, что мне нужно.

А показывает он вот что: на i7-6600U с Kubuntu 18.04 и GCC-8.4 при запуске с тремя рабочими нитями и 50K объектами handler-ами на каждой из рабочих нитей, среднее время цикла вызова on_next_turn для всех 50K handler-ов составляет 7-8ms. Худшие из увиденных результатов -- 10ms.

При этом 50K объектов -- это где-то раза в 3 больше, чем нужно на данный момент.

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

воскресенье, 24 мая 2020 г.

[prog.c++] Особенность привязки агентов к диспетчеру, о которой полезно знать в SObjectizer

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

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

Этой штукой являются SObjectizer-овские диспетчеры. ИМХО, диспетчеры в SObjectizer -- это одна из отличительных черт нашего фреймворка, можно сказать, один из краеугольных камней в его основе.

Программист создавая своих агентов должен указать SObjectizer-у где агент должен работать. Для этого программист создает нужный ему экземпляр диспетчера и "привязывает" агента к этому диспетчеру. После чего агент будет работать на том (и только том) рабочем контексте, который ему выдаст диспетчер.

Диспетчеры могут быть как штатными из SObjectizer-а или so_5_extra, так и написанными самим программистом для своих специфических задач (см. пример). Суть не в этом, а в том, что если диспетчер выдал агенту какой-то рабочий контекст, то агент будет работать именно на этом контексте.

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

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

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

Вполне логично сделать так, чтобы work_manager владел очередью запросов, т.е. эта очередь была бы частью его приватного состояния.

Однако, со временем мы можем прийти к тому, что work_manager будет обрастать все большей и большей функциональностью. Так, мы можем решить, что время нахождения заявки в очереди нужно ограничивать. И у work_manager-а может появится периодическое сообщение, при получении которого work_manager начнет выбрасывать те заявки, которые ждут слишком долго. Потом окажется, что отправитель заявки может захотеть проверять статус заявки и work_manager-у придется начать обрабатывать новое сообщение get_operation_status. Затем может оказаться, что отправитель запроса может захотеть отменить заявку. И work_manager-у придется начать обрабатывать еще одно новое сообщение revoke_operation. А потом мы можем захотеть, чтобы work_manager начал собирать статистику о том, сколько заявки ждут в очереди. И т.д., и т.п.

По мере усложнения агента work_manager мы можем обнаружить, что объем и сложность кода work_manager постоянно растет и мы ничего не можем с этим поделать, т.к. только агенту принадлежит очередь заявок. И со временем нам придется столкнуться с тем, что work_manager вырос в объеме до 3-4-5K строк кода, и вынужден обрабатывать несколько десятков разнообразных сообщений.

Что собенно плохо в ситуациях, когда какая-то новая функциональность добавляется в work_manager только в рамках эксперимента. Ну, например, нам показалось, что поддержка get_operation_status -- это хорошая идея. Мы ее реализовали, попробовали использовать. И достаточно быстро выяснилось, что в реальных сценариях get_operation_status практически не используется, а даже там, где это пытаются использовать, результаты получаются так себе, поскольку в условиях тотальной асинхронности get_operation_status может приносить устаревшие результаты. Поэтому от get_operation_status следует отказаться.

И вот добавление поддержки get_operation_status в более-менее сложный work_manager, а потом изъятие get_operation_status из work_manager, -- это трата времени, да еще и чревато возникновением каких-то ошибок.

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

Тогда мы могли бы иметь work_manager-а, который наполняет очередь и следит за отправкой запросов на обработку. И могли бы иметь агента operation_status_monitor, который бы обратывал get_operation_status.

Каждый из этих агентов работал бы "независимо" от другого. И мы могли бы добавлять новых агентов, а так же изымать старых когда нам этого захочется, без существенной переделки всех остальных агентов. Поскольку они вообще могут не знать друг от друге. Связывали бы этих агентов только общие разделяемые данные.

Но ведь разделяемые данные -- это плохо?

Да, плохо. Особенно когда сущности, которым требуются общие данные, работают на разных рабочих контекстах.

А вот в SObjectizer-е мы можем директивно заставить агентов работать на общем контексте.

Так, мы запросто можем создать one_thread диспетчер и привязать к нему как work_manager, так и operation_status_monitor, так и любого другого агента, которому потребуется доступ к очереди заявок.

И никаких проблем с многопоточностью здесь не будет. Т.к. для привязанных к одному one_thread диспетчеру агентов этой самой многопоточности и нет.


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

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

Но, повторюсь, нужно такое изредка.