Прежде чем перейти к статье, хочу вам представить, экономическую онлайн игру Brave Knights, в которой вы можете играть и зарабатывать. Регистируйтесь, играйте и зарабатывайте!
Увидело свет очередное обновление небольшой библиотеки для встраивания асинхронного HTTP-сервера в C++ приложения: RESTinio-0.6.12. Хороший повод рассказать о том, как в этой версии с помощью C++ных шаблонов был реализован принцип "не платишь за то, что не используешь".
Заодно в очередной раз можно напомнить о RESTinio, т.к. временами складывается ощущение, что многие C++ники думают, что для встраивания HTTP-сервера в современном C++ есть только Boost.Beast. Что несколько не так, а список существующих и заслуживающих внимания альтернатив приведен в конце статьи.
О чем речь пойдет сегодня?
Изначально библиотека RESTinio никак не ограничивала количество подключений к серверу. Поэтому RESTinio, приняв очередное новое входящее подключение, сразу же делала новый вызов accept()
для принятия следующего. Так что если вдруг на какой-то RESTinio-сервер придет сразу 100500 подключений, то RESTinio не заморачиваясь постарается принять их все.
На такое поведение до сих пор никто не жаловался. Но в wish-list-е фича по ограничению принимаемых подключений маячила. Вот дошли руки и до нее.
В реализации были использованы C++ные шаблоны, посредством которых выбирается код, который должен или не должен использоваться. Об этом-то мы сегодня и поговорим.
Проблема
Нужно сделать так, чтобы RESTinio считала параллельные подключения и когда это количество достигает заданного пользователем предела, переставала делать вызовы accept
. Так же RESTinio должна отслеживать моменты закрытия подключений и, если общее количество оставшихся подключений падает ниже заданного пользователем предела, вызовы accept
должны возобновиться.
Проблема состояла в том, что в RESTinio есть две сущности: постоянно живущий объект Acceptor, который принимает новые подключения, и временно живущие объекты Connection, которые существуют пока соединение используется для взаимодействия с клиентом. При этом Acceptor порождает Connection, но далее Connection живет своей собственной жизнью и Acceptor ничего больше о Connection не знает. В том числе не знает когда Connection умирает.
Таким образом, нужно было установить какую-то связь между Acceptor-ом и Connection, чтобы Acceptor мог вести корректный подсчет живых на данный момент соединений.
Фактор "не платишь за то, что не используешь"
Итак, в Acceptor нужно добавить функциональность подсчета количества живых подключений, причем этот подсчет должен быть thread safe, если RESTinio работает в многопоточном режиме. А в Connection нужно добавить обратную ссылку на Acceptor, которая будет использоваться когда Connection разрушается.
Это дополнительные действия и дополнительные расходы. Пусть и небольшие, но если кто-то использует RESTinio в условиях, когда нет смысла ограничивать количество подключений, то зачем нести эти расходы? Правильно, незачем.
Поэтому новая функциональность должна включаться в RESTinio только если пользователь явно пожелал ее использовать. В остальных же случаях добавление ограничения на количество подключений не должно вести к возникновению накладных расходов (либо же эти расходы должны быть сведены к минимуму).
Решение
Ограничение на количество подключений включается/выключается через traits
Как и практически все остальное, включение connection_count_limiter-а в RESTinio осуществляется посредством свойств (traits) сервера. Для того, чтобы connection_count_limiter заработал нужно определить свой класс свойств, в котором должен быть статический constexpr член use_connection_count_limiter
выставленный в true:
struct my_traits : public restinio::default_traits_t {
static constexpr bool use_connection_count_limiter = true;
};
Если теперь задать максимальное количество параллельных подключений и запустить RESTinio-сервер с my_traits
в качестве свойств сервера, то RESTinio начнет считать и ограничивать количество подключений:
restinio::run(
restinio::on_thread_pool<my_traits>(16)
.max_parallel_connections(1000u)
.request_handler(...)
);
Тут используется простой фокус: в предоставляемых RESTinio типах default_traits_t
и default_single_thread_traits_t
уже есть use_connection_count_limiter
, который содержит значение false. Что означает, что connection_count_limiter работать не должен.
Если же пользователь наследуется от default_traits_t
(или от default_single_thread_traits_t
) и определяет use_connection_count_limiter
в своем типе свойств, то пользовательское значение перекрывает старое значение от RESTinio. Но когда пользователь в своем типе свойств не определят свой собственный use_connection_count_limiter
, то остается виден use_connection_count_limiter
из базового типа.
Таким образом RESTinio ожидает, что в traits всегда есть use_connection_count_limiter
. И в зависимости от значения use_connection_count_limiter
уже определяются типы, которые реализуют подсчет количества соединений. Ну или ничего не делают, если use_connection_count_limiter
равен false.
Что происходит, если пользователь задает use_connection_count_limiter=true?
Актуальный connection_count_limiter
Если пользователь задает use_connection_count_limiter
со значением true, то в объекте Acceptor должен появится объект connection_count_limiter, который и будет заниматься подсчетом количества подключений и разрешением/запрещением вызова accept
-ов.
Однако, тут нужно учесть, что RESTinio-сервер может работать в двух режимах:
- однопоточном. Все, включая I/O и обработку принятых запросов, выполняется на одной единственной рабочей нити. В этом случае RESTinio не использует механизмов обеспечения thread safety. Соответственно, и connection_count_limiter-у незачем применять реальный mutex для защиты своих внутренностей;
- многопоточном. И I/O, и обработка принятых запросов может выполняться на разных рабочих нитях. Например, RESTinio сразу запускается на пуле рабочих потоков, на котором выполняются I/O операции и работают обработчики запросов. Либо же RESTinio работает на одной рабочей нити, а реальная обработка запросов делегируется какой-то другой рабочей нити (пулу рабочих нитей). Либо же RESTinio работает на одном пуле рабочих нитей, а обработка запросов делегируется на другой пул рабочих нитей. В этом случае RESTinio задействует механизм strand-ов из Asio для обеспечения thread safety. А connection_count_limiter должен использовать mutex, чтобы не допустить порчи собственных данных когда его начнут дергать из разных нитей.
Поэтому реализация connection_count_limiter-а выполнена в виде шаблонного класса, который параметризуется типом mutex. А нужная реализация выбирается благодаря специализации шаблона:
template< typename Strand >
class connection_count_limiter_t;
template<>
class connection_count_limiter_t< noop_strand_t >
: public connection_count_limits::impl::actual_limiter_t< null_mutex_t >
{
using base_t = connection_count_limits::impl::actual_limiter_t< null_mutex_t >;
public:
using base_t::base_t;
};
template<>
class connection_count_limiter_t< default_strand_t >
: public connection_count_limits::impl::actual_limiter_t< std::mutex >
{
using base_t = connection_count_limits::impl::actual_limiter_t< std::mutex >;
public:
using base_t::base_t;
};
Тип strand-а задается в traits, поэтому достаточно параметризовать connection_count_limiter_t
типом traits::strand_t
и автоматически получается либо версия для однопоточного, либо версия для многопоточного режимов.
Экземпляр connection_count_limiter-а теперь содержится в объекте Acceptor и Acceptor обращается к этому connection_count_limiter-у для того, чтобы узнать, можно ли делать очередной вызов accept
. А connection_count_limiter либо разрешает вызвать accept
, либо нет.
Объект connection_count_limiter получает уведомления от разрушаемых объектов Connection. Если connection_count_limiter видит, что вызовы accept
были заблокированы, а сейчас появилась возможность возобновить прием новых подключений, то connection_count_limiter отсылает нотификацию Acceptor-у. И получив эту нотификацию Acceptor возобновляет вызовы accept
.
А уведомления о разрушении объектов Connection к connection_count_limiter приходят благодаря объектам connection_lifetime_monitor, о которых речь пойдет дальше.
Актуальный connection_lifetime_monitor
В Acceptor-е есть connection_count_limiter который должен узнавать о моментах разрушения объектов Connection.
Очевидным решением было бы реализовать информирование connection_count_limiter-а прямо в деструкторе Connection. Но дело в том, что в RESTinio Connection может преобразовываться в WS_Connection в случае перевода соединения в режим WebSocket-а. Так что аналогичное информирование потребовалось бы делать и в деструкторе WS_Connection-а.
Дублировать же одну и ту же функциональность в двух разных местах не хотелось, поэтому задача информирования connection_count_limiter-а об исчезновении подключения была делегирована новому объекту connection_lifetime_monitor.
Это Noncopyable, но зато Movable объект, который создается внутри Connection. Соответственно, и разрушается он вместе с объектом Connection.
Если же Connection преобразуется в WS_Connection, то экземпляр connection_lifetime_monitor перемещается из Connection в WS_Connection. И затем разрушается уже вместе с владеющим WS_Connection.
Т.е. итоговая схема такая:
- в Acceptor-е живет connection_count_limiter;
- когда Acceptor принимает новое подключение, то вместе с новым Connection создается и новый экземпляр connection_lifetime_monitor;
- когда Connection умирает, то разрушается и connection_lifetime_monitor;
- умирающий connection_lifetime_monitor информирует connection_count_limiter о том, что количество соединений уменьшилось.
Если Connection преобразуется в WS_Connection, то ничего принципиально не меняется, просто актуальную информацию о живом соединении начинает держать у себя connection_lifetime_monitor из WS_Connection.
Подчеркнем, что connection_lifetime_monitor вынужден держать у себя внутри указатель на connection_count_limiter. Иначе он не сможет дернуть connection_count_limiter при своем разрушении.
Фиктивные connection_count_limiter и connection_lifetime_monitor
Выше было показано, что стоит за connection_count_limiter и connection_lifetime_monitor в случае, когда ограничение на количество подключений задано.
Если же пользователь задает use_connection_count_limiter
равным false
, то понятия connection_count_limiter и connection_lifetime_monitor остаются. Но теперь это фиктивные connection_count_limiter и connection_lifetime_monitor, которые, по сути, ничего не делают. Например, фиктивный connection_lifetime_monitor ничего внутри себя не хранит.
Тем не менее, внутри Acceptor-а все еще живет экземпляр connection_count_limiter, пусть даже и фиктивный. А внутри Connection (и WS_Connection) есть пустой connection_lifetime_monitor.
Можно было, конечно, попробовать упороться шаблонами по полной программе и постараться избавиться от присутствия пустого connection_lifetime_monitor в Connection. Но, имхо, наличие лишнего байта в Connection (WS_Connection) не стоит сложности кода, который позволяет от этого байта избавиться. Тем более, что в C++20 добавили атрибут no_unique_address, так что со временем эта проблема должна решиться гораздо более простым и наглядным способом. Впрочем, если для кого-то дополнительный байт в Connection — это реальная проблема, то откройте Issue, будем ее решать :)
Выбор подходящих connection_count_limiter и connection_lifetime_monitor
После того, как появились актуальный и фиктивные реализации connection_count_limiter и connection_lifetime_monitor осталось научиться выбирать между ними в зависимости от содержимого traits. Делается это так:
template< typename Traits >
struct connection_count_limit_types
{
using limiter_t = typename std::conditional
<
Traits::use_connection_count_limiter,
connection_count_limits::connection_count_limiter_t<
typename Traits::strand_t >,
connection_count_limits::noop_connection_count_limiter_t
>::type;
using lifetime_monitor_t =
connection_count_limits::connection_lifetime_monitor_t< limiter_t >;
};
Т.е. для того, чтобы получить актуальный тип connection_count_limiter-а достаточно написать что-то вроде:
typename connection_count_limit_types<traits>::limiter_t
Хранение ограничения на количество подключений в server_settings
Осталось рассмотреть еще один небольшой момент: параметры для RESTinio сервера хранятся в server_settings_t<Traits>
и, по хорошему, надо бы сделать так, чтобы ограничение на количество подключений нельзя было задавать, если в traits use_connection_count_limiter
выставлен в false.
Тут используется фокус, к которому мы уже прибегали раньше:
- создается шаблонный тип, который должен использоваться в качестве примеси (mixin);
- у этого шаблонного типа есть специализация для фиктивного
connection_count_limiter
-а; - этот шаблонный тип подмешивается в качестве базы в server_settings_t.
В самом же server_settings_t
делается метод max_parallel_connections
, который содержит внутри static_assert. Этот static_assert ведет к ошибке компиляции, если в Traits запрещено использовать ограничение на количество подключений. Такой подход, имхо, ведет к более понятным сообщениям об ошибках, нежели отсутствие метода max_parallel_connections
когда use_connection_count_limiter
равен false.
Вместо заключения
RESTinio продолжает развивается по мере наших сил и возможностей. Некоторые планы по дальнейшему развитию есть. Но как-то углубляться в них не хочется из-за суеверных соображений. Уж очень жизненным оказывается афоризм про озвучивание планов и Господа Бога. Такое ощущение, что он срабатывает в 99% случаев :)
Что можно точно сказать, так это то, что мы внимательно прислушиваемся к пожеланиям. Если вам чего-то не хватает в RESTinio, то расскажите нам об этом. Либо прямо здесь, в комментариях, либо на GitHub-е через Issues.
HTTP-клиент в RESTinio?
Время от время мы сталкиваемся с сожалениями потенциальных пользователей о том, что RESTinio реализует только сервер, но не имеет функциональности HTTP-клиента.
Тут все просто. Мы делали RESTinio под конкретные сценарии использования. И это были сценарии использования RESTinio для реализации HTTP-входа в C++ приложения. Клиент нам не был нужен.
Вероятно, реализация клиента в RESTinio может быть добавлена.
Вероятно.
С определенностью сложно сказать, т.к. эту тему мы никогда глубоко не прорабатывали. Если бы кто-то рискнул профинансировать эту работу, то можно было бы всерьез за реализацию клиента взяться. Но за собственный счет мы этот объем просто не поднимем. Поэтому HTTP-клиента в RESTinio нет.
Bonus track: Так Boost.Beast-ом ли единым?
Действительно очень часто на просторах Интернета на вопрос "А что есть в C++ для реализации HTTP-сервера" отвечают Boost.Beast. К моему удивлению часто все еще вспоминают CROW, который уже несколько лет как мертв.
Какие-то другие варианты встречаются довольно редко. Хотя их не так уж и мало. Кроме нашего RESTinio имеет смысл упомянуть, как минимум, следующие разработки (в алфавитном порядке):
- C++ REST SDK от Microsoft;
- drogon;
- Lithium (бывший Silicon Framework);
- oat++;
- Pistache;
- proxygen от Facebook;
- RestBed;
- serverd;
- Simple-Web-Server.
Ну и не забудем про возможности фреймворка POCO.
Так что есть из чего выбирать. И, если вам не нужна экстремальная производительность и тотальный контроль за всеми аспектами, плюс вы хотите обойтись минимумом усилий, то есть смысл сперва рассмотреть альтернативы Boost.Beast. Потому что Beast, при всех своих достоинствах, слишком уж низкоуровневый.