В рамках работ над седьмой бетой SObjectizer 4.4.0 потратил несколько дней на то, чтобы выяснить, почему SObjectizer 4.4.0-b6 на тесте customer_ring показывает серьезное падение производительности (результаты можно увидеть здесь).
Дело оказалось в блокировке ядра SObjectizer4, которая выполнялась внутри send_msg для поиска агента-владельца сообщения, поиска экземпляра сообщения и генерации заявок диспетчерам. Блокировка была простая, на основе обычного mutex-а. Поэтому выполнять все эти операции могла только одна нить. Как следствие, на двух ядрах две рабочие нити постоянно конкурировали друг с другом за этот mutex. И пока одна нить занималась отсылкой сообщения, вторая нить ждала первую на входе в send_msg.
В результате я перевел замок ядра SObjectizer4 с обычного mutex-а на reader-writer mutex (с помощью класса ACE_RW_Thread_Mutex). Общая производительность SObjectizer 4.4.0-b7 несколько снизилась по сравнению с 4.4.0-b6. Зато тест customer_ring на двух ядрах показывает увеличение пропускной способности, а не снижение. И, мне кажется, это очень важно.
Вот конкретные цифры для теста customer_ring (на Core2Duo):
Параметры теста | SO 4.4.0-b6 | SO 4.4.0-b7 |
-N 10000 –M 100 | 395K msg/sec | 361K msg/sec |
-N 10000 –M 100 –D AG –G 2 | 172K msg/sec | 392K msg/sec |
-N 10000 –M 100 –D AG –G 4 | 250K msg/sec | 294K msg/sec |
-N 1000 –M 100 –D AO | 425K msg/sec | 425K msg/sec |
Причем ACE_RW_Thread_Mutex, насколько я могу судить, далеко не самый быстрый вариант reader-writer mutex-а (по крайней мере под Windows). На тестах, когда я вместо ACE_RW_Thread_Mutex использовал счетчики на основе ACE_Atomic_Op, получались результаты в районе 600K msg/sec для двух рабочих нитей. Но проверенной реализации эффективного RW_Mutex-а на основе атомарных операций у меня нет, поэтому пока будет задействован ACE_RW_Thread_Mutex. (Я буду признателен, если кто-нибудь укажет мне на OpenSource реализацию быстрого, кроссплатформенного RW_Mutex-а под BSD/MIT-подобными лицензиями. Или даже под GPL-лицензией, чтобы хотя бы идею оттуда можно было взять.)
Блокировка ядра в SObjectizer 4.4.0-b7 в результате усложнилась. Раньше существовало всего два вида блокировок: блокировка для однофазной операции (например, для подписки события агента или отсылки не отложенного и не периодического сообщения) и блокировка для многофазной операции (например, для регистрации кооперации или отсылки отложенного/периодического сообщения). Теперь добавился еще один вид блокировки: для read-only операции (таковой сейчас является отсылка не отложенного и не периодического сообщения). Было бы желательно еще иметь и read-only многофазную операцию. Но я не придумал, как же просто и эффективно ее реализовать :( Поэтому сейчас отсылка отложенного/периодического сообщения выполняется как многофазная операция и эта операция блокирует все остальные операции (т.е. нельзя параллельно отсылать несколько отложенных сообщений). Не самая лучшая ситуация, конечно. Но хотя бы радует то, что отложенные/периодические сообщения отсылаются гораздо реже обычных сообщений.
Какой же вывод из всего вышеизложенного? А вывод очень простой: архитектура SObjectizer4, рассчитанная на использование одного общего системного словаря и адресация агентов через их имена, оказалась не приспособленной к масштабированию на multicore процессорах. Наверное, в рамках SObjectizer4 еще возможны какие-то оптимизации, способные улучшить производительность SObjectizer4 на нескольких ядрах. Но не на порядки.
Что ж, урок хоть и неприятный, но крайне полезный. Это означает, что SObjectizer5 должен строится совсем на других принципах. Наверное, вот так я это представляю себе сейчас:
Во-первых, никакого общего словаря. Большинство агентов вообще не будут иметь собственных уникальных текстовых имен. Для того, чтобы отослать сообщение конкретному агенту, отправитель сообщения должен иметь прямую ссылку на агента-получателя (например, такой ссылкой может быть некая разновидность умного указателя). Это позволит сразу получать доступ к очереди заявок данного агента, не задействуя никаких глобальных mutex-ов.
Во-вторых, никаких текстовых имен сообщений. Каждое сообщение, которое можно будет отсылать в SObjectizer, будет представлено своим “доменом” – экземпляром специального типа. Например, есть в программе необходимо сообщения типа msg_do_something, то в программе необходимо будет создать домен для этого сообщения. Например, вот так:
struct msg_do_something : public so_5::message_t
{ ... };
so_5::message_domain_t< msg_do_something > msg_do_something_domain;
Подписка и широковещательная отсылка сообщения будет происходить через домен сообщения. Скажем, вот так:
// Подписка на сообщение.
msg_do_something_domain.subscribe( my_agent_ref, &my_agent_t::evt_do_something );
// Широковещательная отсылка сообщения.
std::auto_ptr< msg_do_something > msg( new msg_do_something( ... ) );
msg_do_something_domain.send( msg );
// Целенаправленная отсылка сообщения.
std::auto_ptr< msg_do_something > msg( new msg_do_something( ... ) );
msg_do_something_domain.send( msg, my_agent_ref );
Еще одна идея, которая может пригодиться в SObjectier5. В SObjectizer4 появилось понятие многофазной операции для того, чтобы отложить операцию shutdown (т.е. изъятие информации из системных словарей и очистка некоторых указателей) до тех пор, пока не будет завершена ранее начатая длительная операция. Вот пример кода из SObjectizer4, который иллюстрирует это (отсылка отложенного/периодического сообщения):
// Если сообщение не отложенное, то нужно сразу отослать его,
// а уже затем разбираться с тем, периодическое ли оно или нет.
// После окончания этой операции ядро гарантированно должно
// быть разблокированно.
if( !delay )
{
external_references += deliver_msg_on_blocked_kernel(
kernel_lock,
to_deliver,
insend_dispatching_enabled );
}
else
kernel_lock.unlock();
so_4::rt::impl::kernel_t::m_disp->push_delayed_msg(
timer_msg_data,
timer_delay,
period );
Между if-ом и обращением к push_delayed_msg может произойти операция shutdown на другой нити приложения, и тогда указатель kernel_t::m_disp окажется нулевым. Со всеми вытекающими последствиями. Для того, чтобы такой ситуации не происходило в SObjectizer4 есть специальный счетчик многофазных операций и специальное событие, которое выставляется, когда shutdown разрешен.
Поскольку в SObjectizer5 нужно уйти от глобальных счетчиков и блокировок, нужно как-то решить проблему shutdown-а посреди длительной операции. Грубо говоря, чтобы kernel_t::m_disp не обнулялся пока message_domain занимается генерацией заявок.
Может быть, данная проблема вообще не возникнет, если гарантировать, что все ссылки, которыми располагает агент, не могут измениться в течении всей жизни агента. Т.е. пусть будет интерфейс so_5::runtime_t. Программист должен будет создать объект этого типа в самом начале работы приложения. Затем, ссылка на этот объект будет передаваться в конструкторе всем создающимся в программе агентам. Скажем, в базовом классе so_5::agent_t будет единственный конструктор, требующий ссылку на so_5::runtime_t:
class agent_t
{
private :
so_5::runtime_t & runtime_;
public :
agent_t( so_5::runtime_t & rt ) : runtime_( rt )
{}
...
so_5::runtime_t & runtime() const { return runtime_; }
};
После чего везде, где у нас есть ссылка на агента, можно будет безопасно выполнять обращения к runtime:
// Где-то в дебрях отсылки отложенного сообщения...
receiver.runtime().push_delayed_msg( ... );
В таком случае на программисте будет лежать задача обеспечения корректности ссылки на so_5::runtime_t в течении всего времени работы программы. Что, как мне думается, не очень сложно. Зато в ядре SObjectizer, мне кажется, будет проще обеспечить завершение ранее начатых длительных операций при выполнении операции shutdown.