Проблема С Частым Созданием И Удалением Объектов В C++.

Компания, в которой я работаю, пишет собственную систему фильтрации трафика и использует ее для защиты бизнеса от DDoS-атак, ботов, скраперов и многого другого.

Продукт основан на таком процессе, как обратное проксирование , с помощью которого мы анализируем большие объемы трафика в режиме реального времени и в итоге пропускаем только легитимные запросы пользователей, отфильтровывая все вредоносные.

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

В этом нам помогает наш обширный опыт разработки на современном C++, включая новейшие стандарты и набор библиотек под названием Boost.



Обратное проксирование

Давайте вернемся к обратному проксированию и посмотрим, как его можно реализовать в C++ и boost.asio. Прежде всего, нам нужны два объекта, называемые сеансами сервера и клиента.

Сеанс сервера устанавливает и поддерживает соединение с браузером, сеанс клиента устанавливает и поддерживает соединение со службой.

Вам также понадобится буфер потока, который инкапсулирует работу памяти, которая считывается из сокета сеанса сервера и из которого записывается сокет сеанса клиента.

Примеры сеансов сервера и клиента можно найти в документации boost.asio. Там вы можете увидеть, как работать с буфером потока.

После того, как мы соберем из примеров прототип обратного прокси, станет понятно, что такое приложение, скорее всего, не будет обслуживать неограниченный входящий трафик.

Затем мы начнем увеличивать сложность кода.

Давайте подумаем о многопоточности, ходунках и пулах для контекстов io и многом другом.

В частности, о преждевременных оптимизациях, связанных с копированием памяти между серверными и клиентскими сессиями.

О каком копировании памяти идет речь? Дело в том, что при фильтрации трафик не всегда передается в неизменном виде.

Посмотрите на пример ниже: в нем мы убираем один заголовок и добавляем вместо него два.

Количество пользовательских запросов, над которыми выполняются аналогичные действия, растет по мере усложнения логики внутри сервиса.

В таких случаях никогда не следует бездумно копировать данные! Если изменяется только 1% всего запроса, а 99% остается неизменным, то выделять новую память следует только для этого 1%.

В этом вам помогут boost::asio::const_buffer и boost::asio::mutable_buffer, с помощью которых вы сможете представить несколько непрерывных блоков памяти как одну сущность.

Запрос пользователя:

  
  
  
  
  
  
   

Browser -> Proxy: > POST / HTTP/1.1 > User-Agent: curl/7.29.0 > Host: 127.0.0.1:50080 > Accept: */* > Content-Length: 5888903 > Content-Type: application/x-www-form-urlencoded > .

Proxy -> Service: > POST / HTTP/1.1 > User-Agent: curl/7.29.0 > Host: 127.0.0.1:50080 > Accept: */* > Transfer-Encoding: chunked > Content-Type: application/x-www-form-urlencoded > Expect: 100-continue > .

Service -> Proxy: < HTTP/1.1 200 OK Proxy -> Browser < HTTP/1.1 200 OK



Проблема

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

Запустив его в производство, мы довольно долго радовались тому, насколько хорошо и стабильно он работал.

Со временем у нас стало появляться все больше клиентов, а с их приходом увеличился и наш трафик.

В какой-то момент мы столкнулись с проблемой недостаточной производительности при отражении крупных атак.

Анализ сервиса с помощью утилиты перформанс , мы заметили, что все операции с кучей под нагрузкой находятся в самом верху.

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

Подключившись к услуге через усилитель увидел следующую картинку.

Скриншот усилителя (woslab):

Проблема с частым созданием и удалением объектов в C++.
</p><p>

На скриншоте оператор new работал 67 секунд, а удаление оператора еще дольше — 97 секунд. Эта ситуация нас расстроила.

Как сократить время пребывания приложения в операторе создания и удаления оператора? Логично, что это можно сделать, отказавшись от постоянного размещения в куче часто создаваемых и удаляемых объектов.

Мы остановились на трех подходах.

Два из них стандартные: пул объектов И распределение стека .

Первый подход хорошо работает с клиентскими сессиями, которые объединяются в пул на этапе запуска приложения.

Второй подход используется универсально везде, где пользовательский запрос от начала до конца обрабатывается в одном и том же стеке, другими словами, в одном и том же обработчике контекста io. Мы не будем останавливаться на этом более подробно.

Лучше поговорить о третьем подходе, как о самом сложном и интересном.

Это называется распределение плит или распределение плит. Идея слэбового распределения не нова.

Он был придуман и реализован в Solaris, позже перекочевав в ядро Linux, и заключается в том, что часто используемые объекты одного типа легче хранить в пуле.

Мы просто берем объект из пула, когда он нам нужен, и возвращаем его, когда закончим.

Никаких звонков новому оператору и удалению оператора! При этом минимальная инициализация.

В ядре slab выделение используется для семафоров, файловых дескрипторов, процессов и потоков.

В нашем случае он идеально вписался в серверные и клиентские сессии, а также всё, что находится внутри них.

Схема (распределение плит):

Проблема с частым созданием и удалением объектов в C++.
</p><p>

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

Их мало, а тех, которые активно развиваются, очень мало.

Мы остановились на библиотеке под названием libsmall , что является частью тарантул .

Здесь есть все, что вам нужно.

  • маленький::распределитель
  • small::slab_cache (локальный поток)
  • маленький:: плита
  • маленький::арена
  • маленький::квота
Структура small::slab представляет собой пул объектов определенного типа.

Структура small::slab_cache представляет собой кеш, содержащий различные списки пулов с объектами определенного типа.

Структура small::allocator — это код, который выбирает необходимый кэш, ищет в нем подходящий пул, в котором распределяет запрошенный объект. Что делают объекты small::arena и small::quota, будет понятно из приведенных ниже примеров.



Сворачивать

Библиотека libsmall написана на C, а не на C++, поэтому нам пришлось разработать несколько оболочек для плавной интеграции со стандартной библиотекой C++.

  • варити::slab_allocator
  • варити::плита
  • variti::thread_local_slab
  • variti::slab_allocate_shared
Класс variti::slab_allocator реализует минимальные требования, которые выдвигает стандарт при написании собственного распределителя.

Вся работа с библиотекой libsmall инкапсулирована внутри классов variti::slab. Зачем вам variti::thread_local_slab? Дело в том, что кеши распределения слябов являются локальными объектами потока.

Это означает, что каждый поток имеет свой собственный набор кешей.

Это сделано для того, чтобы свести к нулю количество блокируемых операций при выделении нового объекта.

Поэтому в памяти каждого потока мы размещаем свой экземпляр класса variti::slab и регулируем доступ к нему с помощью обёртки variti::thread_local_slab. О шаблонной функции variti::slab_allocate_shared я расскажу позже.

Внутри класса variti::slab_allocator все довольно просто.

Имеет возможность перепривязки от одного типа к другому, например, от void к char. Интересно, что можно обратить внимание на преобразование nullptr в исключение std::bad_alloc в случае, когда заканчивается память внутри slab-аллокации.

В противном случае это переадресация вызовов внутри оболочки variti::thread_local_slab. Фрагмент (slab_allocator.hpp):

template <typename T> class slab_allocator { public: using value_type = T; using pointer = value_type*; using const_pointer = const value_type*; using reference = value_type&; using const_reference = const value_type&; template <typename U> struct rebind { using other = slab_allocator<U>; }; slab_allocator() {} template <typename U> slab_allocator(const slab_allocator<U>& other) {} T* allocate(size_t n, const void* = nullptr) { auto p = static_cast<T*>(thread_local_slab::allocate(sizeof(T) * n)); if (!p && n) throw std::bad_alloc(); return p; } void deallocate(T* p, size_t n) { thread_local_slab::deallocate(p, sizeof(T) * n); } }; template <> class slab_allocator<void> { public: using value_type = void; using pointer = void*; using const_pointer = const void*; template <typename U> struct rebind { typedef slab_allocator<U> other; }; };

Давайте посмотрим, как реализованы конструктор и деструктор variti::slab. В конструкторе мы выделяем не более 1 ГиБ памяти суммарно для всех объектов.

Размер каждого пула в нашем случае не превышает 1 МиБ.

Минимальный объект, который мы можем выделить, составляет 2 байта (фактически libsmall увеличит его до необходимого минимума — 8 байт).

Остальные объекты, доступные через наше распределение блоков, будут иметь размер, кратный двум (устанавливается константой 2.f).

Всего можно раздавать объекты размеров 8, 16, 32 и т.д. Если запрошенный объект имеет размер 24 байта, то будет накладные расходы по памяти.

Дистрибутив вернет вам этот объект, но он будет помещен в пул, соответствующий 32-байтовому объекту.

Остальные 8 байт будут пустыми.

Фрагмент (slab.hpp):

inline void* phys_to_virt_p(void* p) { return reinterpret_cast<char*>(p) + sizeof(std::thread::id); } inline size_t phys_to_virt_n(size_t n) { return n - sizeof(std::thread::id); } inline void* virt_to_phys_p(void* p) { return reinterpret_cast<char*>(p) - sizeof(std::thread::id); } inline size_t virt_to_phys_n(size_t n) { return n + sizeof(std::thread::id); } inline std::thread::id& phys_thread_id(void* p) { return *reinterpret_cast<std::thread::id*>(p); } class slab : public noncopyable { public: slab() { small::quota_init(& quota_, 1024 * 1024 * 1024); small::slab_arena_create(&arena_, & quota_, 0, 1024 * 1024, MAP_PRIVATE); small::slab_cache_create(&cache_, &arena_); small::allocator_create(&allocator_, &cache_, 2, 2.f); } ~slab() { small::allocator_destroy(&allocator_); small::slab_cache_destroy(&cache_); small::slab_arena_destroy(&arena_); } void* allocate(size_t n) { auto phys_n = virt_to_phys_n(n); auto phys_p = small::malloc(&allocator_, phys_n); if (!phys_p) return nullptr; phys_thread_id(phys_p) = std::this_thread::get_id(); return phys_to_virt_p(phys_p); } void deallocate(const void* p, size_t n) { auto phys_p = virt_to_phys_p(const_cast<void*>(p)); auto phys_n = virt_to_phys_n(n); assert(phys_thread_id(phys_p) == std::this_thread::get_id()); small::free(&allocator_, phys_p, phys_n); } private: small::quota quota_; small::slab_arena arena_; small::slab_cache cache_; small::allocator allocator_; };

Все эти ограничения применяются к конкретному экземпляру класса variti::slab. Поскольку каждый поток имеет свой собственный (помните, что поток локальный), общий лимит на процесс не будет составлять 1 ГиБ, а будет прямо пропорционален количеству потоков, использующих блочное распределение.

Диаграмма (std::thread::id):

Проблема с частым созданием и удалением объектов в C++.
</p><p>

С одной стороны, использование локального потока позволяет ускорить работу слэб-распределения в многопоточном приложении, с другой — накладывает серьезные ограничения на архитектуру асинхронного приложения.

Вам необходимо запросить и вернуть объект в одном потоке.

Сделать это в boost.asio иногда бывает очень проблематично.

Чтобы отслеживать заведомо ошибочные ситуации, в начале каждого объекта мы помещаем идентификатор потока, в котором вызывается метод allocate. Этот идентификатор затем проверяется в методе освобождения.

В этом помогают помощники phys_to_virt_p и virt_to_phys_p. Фрагмент (thread_local_slab.hpp):

class thread_local_slab : public noncopyable { public: static void initialize(); static void finalize(); static void* allocate(size_t n); static void deallocate(const void* p, size_t n); };

Фрагмент (thread_local_slab.cpp):

static thread_local slab* slab_; void thread_local_slab::initialize() { slab_ = new slab(slab_cfg_); } void thread_local_slab::finalize() { delete slab_; } void* thread_local_slab::malloc(size_t n) { return slab_->malloc(n); } void thread_local_slab::free(const void* p, size_t n) { slab_->free(p, n); }

При потере контроля над потоком (при передаче объекта между разными контекстами ввода-вывода) умный указатель позволяет корректно освободить объект. Все, что он делает, — это выделяет объект, запоминая его контекст ввода-вывода, а затем оборачивает его в std::shared_ptr с настраиваемым разделителем, который не возвращает объект сразу в выделение, а делает это в ранее сохраненном контексте ввода-вывода.

Это хорошо работает, когда каждый контекст ввода-вывода выполняется в одном потоке.

В противном случае, к сожалению, этот подход неприменим.

Фрагмент (slab_helper.hpp):

template <typename T, typename Allocator, typename. Args> std::shared_ptr<T> slab_allocate_shared(Allocator allocator, Args. args) { T* p = allocator.allocate(1); new ((void*)p) T(std::forward<Args>(args).

); std::shared_ptr<T> ptr(p, [allocator](T* p) { p->~T(); allocator.deallocate(p); }); return ptr; }; template <typename T, typename Allocator, typename. Args> std::shared_ptr<T> slab_allocate_shared(Allocator allocator, boost::asio::io_service* io, Args. args) { T* p = allocator.allocate(1); new ((void*)p) T(std::forward<Args>(args).

); std::shared_ptr<T> ptr(p, [allocator, io](T* p) { io->post([allocator, p]() { p->~T(); allocator.deallocate(p); }); }); return ptr; };



Решение

После того, как оболочка libsmall была завершена, первое, что мы сделали, — это преобразовали распределители фрагментов внутри буфера потока в slab. Сделать это было довольно легко.

Получив положительный результат, мы пошли дальше и применили slab allocators сначала к самому буферу потока, а затем ко всем объектам внутри серверной и клиентской сессий.

  • варити::кусок
  • варити::streambuf
  • варити::server_session
  • варити::client_session
При этом нам пришлось решать дополнительные задачи, а именно: конвертировать простые объекты, составные объекты и коллекции в slab allocators. И если с первыми двумя классами объектов серьезных трудностей не возникло (составные объекты сводятся к простым), то при переводе коллекций мы столкнулись с серьезными трудностями.

  • станд::список
  • станд::дек
  • станд::вектор
  • станд::строка
  • станд::карта
  • std::unordered_map
Одним из основных ограничений при работе с плиточным распределением является то, что количество объектов разного типа не должно быть слишком большим (чем оно меньше, тем лучше).

В этом контексте некоторые коллекции могут хорошо вписываться в концепцию распределителей плит, а другие — нет. Распределители отлично подходят для std::list slab. Эта коллекция реализована внутренне с использованием связанного списка, каждый элемент которого имеет фиксированный размер.

Таким образом, при добавлении новых данных в std::list новые типы объектов не появляются в раздаче slab. Вышеупомянутое условие соблюдено! std::map работает аналогично.

Разница лишь в том, что внутри него не связный список, а дерево.

В случае с std::deque все сложнее.

Эта коллекция реализуется посредством непрерывного блока памяти, содержащего указатели на фрагменты.

Пока кусков достаточно, std::deque ведет себя так же, как std::list, но когда они заканчиваются, этот же блок памяти перераспределяется.

С точки зрения распределителей слябов каждое перераспределение памяти представляет собой объект нового типа.

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

Такая ситуация неприемлема, поэтому мы либо заранее ограничили размер std::deque, где это возможно, либо отдали предпочтение std::list. Если взять std::vector и std::string, то все еще сложнее.

Реализация этих коллекций чем-то похожа на std::deque, за исключением того, что их непрерывный блок памяти растет заметно быстрее.

Мы заменили std::vector и std::string на std::deque, а в худшем случае — на std::list. Да, мы потеряли в функциональности и где-то даже в производительности, но на итоговую картинку это повлияло гораздо меньше, чем оптимизации, для которых все было задумано.

Точно то же самое мы сделали и с std::unordered_map, отказавшись от него в пользу самописного variti::flat_map, реализованного через std::deque. В данном случае мы просто кешировали часто используемые ключи в отдельные переменные, например, как это сделано с заголовками http-запросов в nginx.

Заключение

Выполнив полный перенос серверных и клиентских сессий на slab allocators, мы сократили время работы с кучей более чем в полтора раза.

Скриншот усилителя (coldslab):

Проблема с частым созданием и удалением объектов в C++.
</p><p>

На скриншоте создание оператора заняло 32 секунды, а удаление оператора — 24 секунды.

К этому времени были добавлены другие функции для работы с кучей: smalloc — 21 секунда, mslab_alloc — 37 секунд, smfree — 8 секунд, mslab_free — 21 секунда.

Итого, 143 секунды против 161 секунды.

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

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

Скриншот усилителя (hotslab):

Проблема с частым созданием и удалением объектов в C++.
</p><p>

На скриншоте оператор new работал 20 секунд, smalloc — 16 секунд, mslab_alloc — 27 секунд, оператор delete — 16 секунд, smfree — 7 секунд, mslab_free — 17 секунд. Итого, 103 секунды против 161 секунды.

Таблица измерений:

woslab coldslab hotslab operator new 67s 32s 20s smalloc - 21s 16s mslab_alloc - 37s 27s operator delete 94s 24s 16s smfree - 8s 7s mslab_free - 21s 17s summary 161s 143s 103s

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

Без slab операции создания оператора и удаления оператора со временем будут только замедляться.

С плитой он всегда будет оставаться на одном уровне.

Как мы видим, слябовые аллокаторы успешно решают проблему выделения памяти для часто используемых объектов.

Обратите на них внимание, если для вас актуален вопрос частого создания и удаления объектов.

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

Это может быть просто, когда вы сразу проработали архитектуру приложения с учетом использования slab-распределителей, но определенно вызовет трудности, когда вы решите их интегрировать на более позднем этапе.



Приложение

Вы можете просмотреть исходный код здесь ! Теги: #linux #программирование #открытый исходный код #C++ #backend #network #boost #tcmalloc #slab
Вместе с данным постом часто просматривают:

Автор Статьи


Зарегистрирован: 2019-12-10 15:07:06
Баллов опыта: 0
Всего постов на сайте: 0
Всего комментарий на сайте: 0
Dima Manisha

Dima Manisha

Эксперт Wmlog. Профессиональный веб-мастер, SEO-специалист, дизайнер, маркетолог и интернет-предприниматель.