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 очередь будет получать объект с реализацией этого интерфейса в конструкторе, сохранять у себя и дергать методы данного объекта по мере надобности:
template< typename 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:
template< typename 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 полиморфизим без трехэтажных шаблонов в публичном интерфейсе библиотеки.
Такие дела. Так что выбирай. Но осторожно. Но выбирай ;)
Комментариев нет:
Отправить комментарий