Компания, в которой я работаю, разрабатывает собственную систему фильтрации трафика и защищает веб-ресурсы от ddos-атак, ботов, парсеров и мошенничества. Наш обратный прокси позволяет анализировать огромные объемы трафика в режиме реального времени. Основная особенность заключается в том, что система обрабатывает неограниченное количество входящих данных, поэтому мы должны максимально эффективно использовать все ресурсы рабочей станции. Мы можем достичь такого уровня эффективности благодаря нашему обширному опыту работы с языком программирования C ++ (включая последние стандарты) и библиотеками Boost.

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

Но через некоторое время, когда наши клиенты и трафик увеличились, мы столкнулись с проблемой, что нам не хватало аппаратных ресурсов во время массовых атак. Затем мы проанализировали сервис с помощью утилит perf и увидели, что все операции с кучей под нагрузкой остаются наверху. Мы смоделировали такую ​​ситуацию в тестовой среде с помощью яндекс-танка и пула запросов, сформированных на основе реального трафика. После подключения к сервису через усилитель мы увидели следующее:

На скриншоте видно, что оператор new проработал 67 секунд, а оператор delete - 97 секунд.

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

Размещение плит - не новый метод. Он был создан и реализован в Solaris, а затем перенесен в ядро ​​Linux. Идея размещения блоков состоит в том, чтобы хранить объекты одного типа в пуле. При необходимости мы берем объект из пула и после завершения работы возвращаем его в пул, и все это без использования operator new или operator delete! Кроме того, выделение слэба требует минимального времени для инициализации. Выделение слэбов в ядре Linux используется для семафоров, файловых дескрипторов, процессов и потоков. В нашем случае такой подход отлично подходит для серверных и клиентских сессий.

В пользовательском пространстве есть несколько реализаций slab-распределителей. Мы решили выбрать библиотеку libsmall, которая входит в состав tarantool. Библиотека содержала все необходимые структуры:

  • small :: allocator
  • small :: slab_cache (локальный поток)
  • small :: slab
  • small :: arena
  • small :: quota

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

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

  • variti :: slab_allocator
  • variti :: плита
  • variti :: thread_local_slab
  • variti :: slab_allocate_shared

Класс variti :: slab_allocator соответствует минимальным требованиям, предъявляемым стандартной библиотекой C ++ для распределителей. Классы variti :: slab и variti :: thread_local_slab инкапсулируют всю работу с библиотекой libsmall. Но зачем вам variti :: thread_local_slab? Дело в том, что кеш-память распределителя блоков - это локальные объекты потока. Это означает, что каждый поток имеет свой собственный кэш, чтобы минимизировать количество заблокированных операций во время выделения нового объекта. Поэтому мы размещаем объект класса variti :: slab в памяти каждого потока. Итак, variti :: thread_local_slab - это локальный синглтон потока для variti :: slab. Мы обсудим функции шаблона variti :: slab_allocate_shared позже.

Класс variti :: slab_allocator довольно прост. Он может выполнять повторную привязку от одного типа выделенного объекта к другому. Вы также можете заметить, что nullptr преобразуется в исключение std :: bad_alloc, если для выделения slab не хватает памяти.

Давайте рассмотрим конструктор и деструктор variti :: slab. В конструкторе мы выделяем для всех объектов не более 1 ГиБ памяти. Размер каждого пула в нашем случае не более 1 МиБ. Минимальный размер объекта, который мы можем выделить, составляет 2 байта (libsmall увеличит его до минимально необходимых 8 байтов). Другие объекты, доступные через наше выделение slab, будут иметь размер, кратный двум (определяется константой 2.f). В результате вы можете размещать объекты размером 8, 16, 32 и так далее. Если запрошенный объект имеет размер 24 байта, вы столкнетесь с накладными расходами памяти: выделение slab вернет объект, и он будет помещен в пул из 32-байтовых объектов, остальные 8 байтов останутся неиспользованными.

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

С одной стороны, использование локальных объектов потока позволяет эффективно распределять блоки в многопоточном приложении. С другой стороны, архитектура асинхронного приложения строго ограничена. Вы должны запросить и вернуть объект размещения плиты в том же потоке. В boost.asio это действительно сложно сделать. Чтобы отслеживать ошибочные ситуации, мы помещаем идентификатор потока в начало каждого объекта. Затем идентификатор проверяется в утверждении. Посмотрите на помощников Phys_to_virt_p и virt_to_phys_p.

Хотя два объекта передаются между разными контекстами io, вы можете правильно освобождать объекты, используя интеллектуальный указатель, который возвращается из variti :: slab_allocate_shared. Интеллектуальный указатель выделяет объект и запоминает его контекст io в пользовательском средстве удаления std :: shared_ptr. Это средство удаления не возвращает объект сразу в выделение slab, а помещает его в ранее сохраненный контекст io. Этот метод хорошо работает, когда каждый контекст io выполняется в одном потоке. В остальном, к сожалению, такой подход не применим.

После того, как мы закончили работу над упаковкой libsmall, мы применили slab-распределители к фрагментам внутри boost.asio.streambuf. Это было довольно просто. Затем мы применили распределители slab ко всем объектам внутри сеанса сервера и сеанса клиента.

Мы разделили наши приложения на применение простых объектов, сложных объектов и коллекций. С первыми двумя классами объектов (преобразование сложных объектов в простые) сложностей не возникло. Итак, проблемы возникли только с коллекциями:

  • std :: list
  • std :: deque
  • std :: vector
  • std :: string
  • std :: map
  • std :: unordered_map

Для блочных распределителей 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: : список. Конечно, мы потеряли часть функциональности, но на производительности это сказалось значительно меньше.

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

В результате мы сократили время работы с кучей более чем в полтора раза:

На скриншоте видно: оператор new работает 32 секунды, а оператор delete - 24 секунды. Были добавлены другие функции кучи: smalloc - 21 секунда, mslab_alloc - 37 секунд, smfree - 8 секунд, mslab_free - 21 секунда. Общий результат - 143 секунды против 161 секунды.

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

На скриншоте видно: оператор new работает 20 секунд, а оператор delete - 16 секунд. Когда smalloc - 16 секунд, mslab_alloc - 27 секунд, 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-распределители решают не только проблему длительного выделения и освобождения памяти, но и уменьшают фрагментацию. Без выделения плиты operator new со временем замедлится. Благодаря размещению плит производительность остается постоянной.

Как видим, slab-распределители эффективно решают проблему выделения памяти и освобождения часто используемых объектов. Мы настоятельно рекомендуем использовать плиту, если у вас тоже есть эта проблема. Но не забывайте об ограничениях, установленных плитой для архитектуры вашего приложения! Не все сложные объекты можно просто разместить в плите. Иногда приходится идти на компромисс. Чем сложнее ваша архитектура, тем чаще вам нужно заботиться о возврате объекта для исправления кеша. Было бы проще, если бы вы приняли во внимание распределители slab в начале вашей работы над архитектурой. Если позже вы захотите реализовать плиту, то обязательно столкнетесь с некоторыми трудностями.

Пожалуйста, посмотрите исходный код на github!