четверг, 19 ноября 2015 г.

[prog.c++11] Пример влияния проектных решений на производительность, а совместимости...

...на невозможность просто так поменять эти решения.

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

Итак, если мы берем инструмент вроде SObjectizer или C++ Actor Framework, то мы получаем некоторый набор фич, присутствующих в инструменте, плюс некоторую цену, которую за эти фичи нужно платить. Причем "цена" -- это интегральный показатель, объединяющий в себя ряд факторов. Одним из которых является производительность инструмента. Применительно к фреймворкам, реализующим модель акторов, ключевой показатель производительности -- это скорость обмена сообщениями.

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

Оглядываясь назад приходится констатировать, что самая главная засада, которую мы сами себе устроили, -- это первоначальное отсутствие механизма обмена сообщениями one-to-one. В начале был только one-to-many механизм. Соответственно, сначала появились почтовые ящики (mbox-ы) типа Multi-Producer/Multi-Consumer (MPMC).

При этом мы решили при доставке сообщения доставлять один и тот же экземпляр сообщения всем получателям.

Привело это к двум вещам, сказывающимся на производительности SObjectizer-а:

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

Во-вторых, нельзя сам экземпляр сообщения поместить в очередь диспетчера, т.к. сообщение может уйти сразу в несколько очередей (или несколько раз в одну и ту же очередь). В очереди диспетчера должны храниться другие объекты (в SO-5 они называются execution_demands), в которых находится дополнительная информация, связанная с доставкой конкретного экземпляра конкретному получателю. А создание дополнительного execution_demand -- это дополнительная аллокация (иногда явная и дорогая, если очередь заявок диспетчера строится на основе одно- или двусвязных списков, иногда неявная и более дешевая, если очередь заявок лежит внутри deque или vector-а).

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

Появление некоторое время назад Multi-Producer/Single-Consumer (MPSC или direct) mbox-ов ситуацию несколько улучшило, но лежащий в основе механизма диспетчеризации принцип использования execution_demand-ов все равно остался.

Ситуация была бы совершенно другой, если бы мы изначально шли от механизма взаимодействия one-to-one и mbox-ы были бы MPSC-mbox-ами. Тогда:

  • экземпляр сообщения доставлялся бы всего одному получателю, что избавляло бы нас от необходимости использовать счетчик ссылок и позволяло бы уничтожать экземпляр сообщения сразу после выхода из обработчика события;
  • экземпляр сообщения мог бы содержать в себе необходимые поля (m_next и, возможно, m_prev) для провязывания самого экземпляра в список ожидающих обработки сообщений. Т.о. нам не нужен был бы execution_demand и мы бы избавились от дополнительной аллокации памяти для execution_demand-а.

А механизм one-to-many (т.е. широковещательная рассылка сообщения группе получателей) мог бы быть реализован не через еще один тип mbox-ов, а через другое понятие. Скажем, через что-либо под названием msg_board или broadcaster:

  • агент, который хочет получать сообщения какого-то типа от msg_board-а, связывает свой mbox с этим board-ом (по аналогии с тем, как сейчас происходит подписка на MPMC-mbox-ы);
  • отправитель сообщения "публикует" его на msg_board-е и msg_board дублирует сообщение в каждый подписавшийся на этот тип mbox. Именно дублирует, т.е. создает отдельные экземпляры сообщений для каждого mbox-а.

Вот именно в дублировании экземпляров сообщений при one-to-many рассылках и должен быть весь секрет. Полагаю, что в большинстве задач это было бы не просто допустимо, но еще и эффективно, т.к. создание копии мелкого сообщения было бы не дороже, чем создание дополнительных execution_demand-ов. А в более редких случаях, когда в сообщениях передаются "тяжелые" данные или объекты, не допускающие копирования, можно было бы оформлять такие данные в отдельный объект, а в сообщении отсылать shared_ptr.

Почему мы изначально, в 2010-ом году, так не сделали? Это не интересный и бесполезный вопрос, на самом-то деле. Потому что сложно искать на него ответ пытаясь игнорировать послезнание. Видимо, причины в том, что ни у кого из нас не было опыта работы с C++11 в то время (строго говоря, и самого C++11 официально еще не было). А многие вещи, которые самым активным образом используются при работе с SObjectizer-ом сейчас (те же variadic templates, к примеру), появились в нашем распоряжении лишь спустя несколько лет после начала использования SO-5 в продакшене. А без этого всего в 2010-ом было просто нереально сделать то, что я бы хотел сделать сейчас.

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

В сухом остатке: пять лет назад в основу SO-5 были положены проектные решения, которые приводят к несколько большим накладным расходам на диспетчеризацию сообщений, чем у аналогичных фреймворков (вроде CAF или Just::Thread Pro). Исправить эти решения кардинально нарушив совместимость между версиями SObjectizer не есть хорошо. Можно ли сделать эти исправления в рамках развития SO-5... Большой вопрос. Нужно думать.

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

PS. Кстати говоря, используемый в SO-5 подход очень хорошо сочетается с отложенными и периодическими сообщениями. Чего нельзя сказать об описанном выше подходе, в котором MPSC-mbox-ы и msg_board-ы сами по себе.


Смешно признаться, но сам я замеров производительности и сравнения скорости между SO-5 и "конкурентами" не производил. Во-первых, тот CAF не поддерживает MSVC++, а я работаю под Windows. Linux у меня только под виртуалкой, но проведение бенчмарков под виртуалками -- это вообще не серьезно. Во-вторых, синтетические бенчмарки -- это одно, а производительность реальных приложений -- совсем другое. Поэтому бенчмарк нужно делать под какую-то жизненную задачу, а таковой лично у меня нет (тут вот люди, которые пишут свою реализацию модели акторов на C++, говорили, что они делали подобное сранение для таких задач и SO-5 несколько отставал от их собственного решения, хотя и шел вровень с CAF).

Комментариев нет: