Компания, в которой я работаю, разрабатывает собственную систему фильтрации трафика и защищает веб-ресурсы от 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!