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

[prog.c++] Run-time polymorphism vs Compile-time polymorphism. Выбирай. Но осторожно. Но выбирай.

C++ -- это гибридный язык, он поддерживает несколько стилей(парадигм) программирования. Что при проектировании отдельных кусков программы заставляет делать выбор в пользу одного или другого стиля. В частности это касается полиморфизма: будет ли код использовать run-time полиморфизм или же compile-time.

Представим себе, что нам нужно сделать простую multi-producer/single-consumer очередь объектов. Так как доступ к ней будет конкурентным, нужно защитить содержимое очереди посредством какого-то объекта синхронизации. В самом простом случае нам достаточно взять готовые mutex и condition_variable и написать тривиальную реализацию очереди, дергающую методы mutex-а и condition_variable.

Но что делать, если мы хотим задействовать свою MPSC очередь в разных сценариях, для которых может потребоваться что-то кроме mutex и condition_variable из стандартной библиотеки? Например, спинлок с busy waiting-ом или низкоуровневый примитив конкретной ОС, работающий более эффективно, чем реализация std::mutex-а для этой ОС.

Нам придется вводить новую абстракцию, скажем, queue_lock, и писать код MPSC очереди с использованием этой абстракции. Но чем именно будет эта самая абстракция queue_lock?

Если мы придерживаемся ОО-подхода и run-time полиморфизма, то queue_lock будет оформлен в виде абстрактного класса с чистыми виртуальными методами (т.е. в виде интерфейса в терминологии некоторых языков программирования). Что-то вроде:

class queue_lock
   {
   public :
      virtual void lock() = 0;
      virtual void unlock() = 0;

      virtual void wait() = 0;
      virtual void notify_one() = 0;
   };

Наша MPSC очередь будет получать объект с реализацией этого интерфейса в конструкторе, сохранять у себя и дергать методы данного объекта по мере надобности:

templatetypename T >
class mpsc_queue
   {
   public :
      mpsc_queue( std::unique_ptr< queue_lock > lock )
         :  m_lock{ std::move(lock) }
         {}

      void push( T obj )
         {
            std::lock_guard< queue_lock > l{ *m_lock };
            ...
         }

      T pop()
         {
            std::lock_guard< queue_lock > l{ *m_lock };
            ...
         }
      ...
   private :
      std::unique_ptr< queue_lock > m_lock;
      ...
   };

Мы можем иметь несколько фабрик, создающих разные реализации queue_lock, и выбирать нужную нам фабрику непосредственно в run-time. Например, исходя из параметров в конфигурации приложения: выставлен параметр "максимальная производительность" -- задействовали реализацию на основе спинлоков. Не выставлен -- на базе std::mutex-а.

Гибко, но не бесплатно. Во-первых, у нас появляется лишняя косвенность вызовов методов lock/unlock/wait/... Во-вторых, разрушается локальность данных. Объект lock может лежать в памяти далеко от остальных данных MPSC очереди и в методах push/pop сначала придется лезть за объектом lock в одну область памяти, а затем, за данными самой очереди, в совсем другую область.

Если же мы задействуем compile-time полиморфизм, то сможем избавиться от этих накладных расходов. Для этого нам потребуется сделать тип объекта-синхронизации еще одним параметром шаблона mpsc_queue:

templatetypename T, typename LOCK >
class mpsc_queue
   {
   public :
      mpsc_queue()
         {}

      void push( T obj )
         {
            std::lock_guard< LOCK > l{ m_lock };
            ...
         }

      T pop()
         {
            std::lock_guard< LOCK > l{ m_lock };
            ...
         }
      ...
   private :
      LOCK m_lock;
      ...
   };

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

Но расплачиваться за это нужно как потерей гибкости в run-time (т.е. мы уже не сможем просто так выбрать тип объекта синхронизации в run-time в зависимости от настроек приложения), так и вытаскиванием потрохов реализации низкоуровневых вещей наверх.

На счет "вытаскивания потрохов" можно сказать пару слов. Вряд ли MPSC очередь будет главной структурой данных в приложении. Скорее всего это будет лишь один из кирпичиков. Но там, где нам потребуется задействовать этот кирпичек, у нас теперь будет маячить дополнительный параметр шаблона. Например, MPSC очередь может быть частью реализации понятия topic в механизме publish-subscribe. Значит, topic должен задавать тип объекта синхронизации для своих MPSC очередей. Хорошо, если на уровне topic-а мы можем выбрать и зафиксировать этот тип. Но может случиться и так, что тип LOCK нужно будет делать параметром шаблона и для самого topic-а. Что означает, как минимум, две вещи: во-первых, детали реализации конкретного LOCK-а будут нас заботить где-то на еще более высоких уровнях абстракции. Что-то вроде:

class data_bus
   {
      topic< temperature_samples, spinlock_queue_lock > m_temperatures;
      topic< fan_status, spinlock_queue_lock > m_fan_status;
      topic< fan_control, spinlock_queue_lock > m_fan_control;
      topic< config_change, mutex_queue_lock > m_config_changes;
      ...
   };

Т.е. определяя потоки данных приложения (что делается на достаточно высоком уровне абстракции) мы вынуждены оперировать типами объектов синхронизации (что является довольно низким уровнем абстракции).

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

Вот такая вот картинка. С одной стороны -- гибкость в run-time плюс возможность добавления новых реализаций без необходимости перекомпиляции старого кода. Но ценой некоторого снижения производительности. С другой -- высокая скорость работы, но без гибкости в run-time и с некоторой головной болью при разработке.

И что-то мне не видится простых методов симбиоза этих двух подходов. Что не есть хорошо при разработке библиотек, например. Поскольку если потребуется вынести MPSC очереди и topic-и в отдельную библиотеку, то нужно будет сразу решать, используется ли полиморфизм времени исполнения или же времени компиляции. Соответственно, реализация библиотеки будет либо одной, либо другой. А вот сделать единую реализацию, которая в зависимости от набора #define превращается либо в вариант с run-time полиморфизмом, либо в вариант с compile-time полиморфизмом, наверное и не получится. А если и получится, то это будет такой монстр, в исходники которого заглядывать будет страшно.

Что еще добавляет пикантности, так это то, что язык C++ оправдан там, где требуется высокая эффективность. Что наводит на мысль, что выбор следует делать в пользу compile-time полиморфизма (и, как следствие использования шаблонов, header-only библиотек). Но, с другой стороны, раз уж C++ поддерживает разные стили и пользуются им совсем разные люди для совершенно разных задач, то для многих из C++ разработчиков более удобным были бы библиотеки, использующие run-time полиморфизим без трехэтажных шаблонов в публичном интерфейсе библиотеки.

Такие дела. Так что выбирай. Но осторожно. Но выбирай ;)

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