[Перевод] Создаем свою STL-совместимую реализацию std::allocator с лучшей производительностью

729998b260d8e999e8175bd613fdfcec.png

Реализация защиты от сбоев из-за фрагментации кучи и повышение скорости выполнения с помощью STL-альтернативы std::allocator, работающей с блоками памяти фиксированного размера.

В этой статье описывается реализация STL-совместимого аллокатора, ориентированного на выделение и высвобождение блоков памяти фиксированного размера. Предложенный аллокатор предотвращает сбои, вызванные фрагментированной кучей, и обеспечивает стабильное время выполнения выделения/высвобождения памяти. Моей главной целью при создании stl_allocator было устранение ошибок памяти. Вдобавок использование STL-совместимого блочного аллокатора открывает возможность использования функций стандартной библиотеки шаблонов (STL) C++ в проектах, в которых иначе это было бы невозможно.

Скачать исходный код — 20.6 KB

Введение

Это моя третья и последняя статья, посвященная аллокаторам, работающим с фиксированными блоками памяти, на Code Project. И на этот раз мы создадим полноценную альтернативу менеджеру памяти std::allocator из стандартной библиотеки C++, опираясь на основы, заложенные в первых двух статьях. 

Стандартная библиотека шаблонов (STL) — это мощная программная библиотека C++, предлагающая поддержку контейнеров и итераторов. Проблема с использованием библиотеки в критически важных или ограниченных по времени обработки проектах заключается не в самой STL — библиотека очень надежна. Проблема заключается в неограниченном использовании глобальной кучи (global heap). Стандартный аллокатор STL интенсивно использует кучу во время работы. Это представляет серьезную проблему для встраиваемых систем с ограниченными ресурсами. Встраиваемые устройства могут работать без остановки месяцами или даже годами, и в этом случае просто необходимо предотвратить сбои, вызванные фрагментацией кучи. К счастью, STL предоставляет возможность заменить std: allocator на аллокатор собственной разработки. 

Фиксированные (fixed block) аллокаторы — распространенная техника, обеспечивающая около-динамическую работу, но при этом выделяющая память из пулов, где блоки имеют фиксированный размер. В отличие от глобальной кучи, которая должна универсально работать с блоками любого размера, фиксированный аллокатор может быть адаптирован под узкоспециализированные задачи. Аллокаторы с фиксированными блоками памяти также могут обеспечить стабильное время выполнения, в то время как глобальная куча не может предоставить таких гарантий. 

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

std: allocator

STL-класс std::allocator реализует стратегию выделения и высвобождения памяти по умолчанию. Если вы взгляните на код класса-контейнера, такого как std::list, то увидите там аргумент шаблона по умолчанию std::allocator. В данном случае allocator<_Ty> — это шаблонный класс, разбирающийся с хлопотами аллокации для объектов _Ty.

template >
class list : public _List_val<_Ty, _Ax>
{
    // ...
}

Поскольку параметр шаблона _Ax по умолчанию принимает значение allocator<_Ty>, вы можете создать объект-список, не указывая аллокатор вручную. Объявление myList, как показано ниже, создает класс аллокатора для выделения/высвобождения значений int

std::list myList;

Для своих элементов и узлов контейнеры STL полагаются на динамическую память. Элемент (element) имеет размер вставленного объекта. В данном случае sizeof(int) — это память, необходимая для хранения одного элемента списка. Узел (node) отражает внутреннюю структуру, необходимую для связывания элементов вместе. Для std::list это двунаправленный связный список, хранящий, как минимум, указатели на следующий и предыдущий узлы. 

В то время, как размер элемента зависит от хранимых объектов, размер узла также зависит от используемого контейнера. Узел std::list может иметь другой размер, нежели узел std::map. В связи с этим STL-совместимый аллокатор должен уметь обрабатывать запросы на блоки памяти разного размера. 

STL-совместимый аллокатор должен придерживаться определенных требований к интерфейсу. Но эта статья не о том, как и почему работает API std::allocator — на просторах интернета есть множество ссылок, которые объяснят это куда лучше, чем я. Вместо этого я сосредоточусь на том, где разместить вызовы аллокации/деаллокации памяти в существующем интерфейсе класса stl_allocator, и предоставлю новые «x»-версии всех популярных контейнеров для упрощения использования.  

xallocator    

Большая часть работы представленного фиксированного STL-аллокатора с приходится на базовый класс xallocator, как описано в моей статье «Замена malloc/free быстрым аллокатором фиксированными блоками памяти». Как сказано в названии, этот модуль заменяет malloc/free новыми версиями xmalloc/xfree, работающими с блоками памяти фиксированного размера. 

С точки зрения пользователя эти новые функции работают так же, как и стандартные CRT-версии, за исключением работы с фиксированными блоками памяти. Вкратце, xallocator имеет два режима работы: статические пулы (static pools), когда вся память берется из заранее объявленной статической памяти, или блоки кучи (heap blocks), когда блоки берутся из глобальной кучи, но при высвобождении перерабатываются для последующего использования. Подробности реализации смотрите в вышеупомянутой статье. 

stl_allocator

Класс stl_allocator — это реализация STL-совместимого фиксированного аллокатора. Этот класс используется как альтернатива std::allocator

template 
class stl_allocator
{
public:
    typedef size_t size_type;
    typedef ptrdiff_t difference_type;
    typedef T* pointer;
    typedef const T* const_pointer;
    typedef T& reference;
    typedef const T& const_reference;
    typedef T value_type;

    stl_allocator(){}
    ~stl_allocator(){}

    template  struct rebind { typedef stl_allocator other; };
    template  stl_allocator(const stl_allocator&){}

    pointer address(reference x) const {return &x;}
    const_pointer address(const_reference x) const {return &x;}
    size_type max_size() const throw() {return size_t(-1) / sizeof(value_type);}

    pointer allocate(size_type n, stl_allocator::const_pointer hint = 0)
    {
        return static_cast(xmalloc(n*sizeof(T)));
    }

    void deallocate(pointer p, size_type n)
    {
        xfree(p);
    }

    void construct(pointer p, const T& val)
    {
        new(static_cast(p)) T(val);
    }

    void construct(pointer p)
    {
        new(static_cast(p)) T();
    }

    void destroy(pointer p)
    {
        p->~T();
    }
};

По сути код представляет из себя просто стандартный интерфейс std::allocator. В сети можно найти множество подобных примеров. Исходный код, приложенный к этой статье, использовался во многих различных компиляторах (GCC, Keil, VisualStudio). Нас интересует, как подключиться к интерфейсу с помощью собственного менеджера памяти. А именно, нас интересуют следующие методы:

  • allocate()

  • deallocate()

allocate() выделяет n экземпляров объекта типа T. xmalloc() используется для получения памяти из пула блоков фиксированного размера, а не из глобальной кучи.

pointer allocate(size_type n, stl_allocator::const_pointer hint = 0)
{
    return static_cast(xmalloc(n*sizeof(T)));
}

Функция deallocate() высвобождает блок памяти, ранее выделенный с помощью функции allocate(). Простой вызов xfree() направляет запрос нашему менеджеру памяти. 

void deallocate(pointer p, size_type n)
{
    xfree(p);
}

На самом деле, это все, что вам нужно сделать, если у вас есть менеджер памяти, работающий с блоками фиксированного размера. xallocator предназначен для обработки запросов на память любого размера. Т.е. независимо от размера памяти, требуемого стандартной библиотекой C++ под конкретные элементы или узлы, xmalloc/xfree обработает этот запрос.

Разумеется, stl_allocator — это шаблонный класс. Обратите внимание, что обязанности по выделению фиксированных блоков делегированы нешаблонным функциям xmalloc() и xfree(). Это позволяет минимизировать количество инстанцируемого кода для каждого экземпляра. 

«x»-контейнеры

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

  • std::list

  • std::map

  • std::multipmap

  • std::set

  • std::multiset

  • std::queue

Для того, чтобы сделать использование stl_allocator немного проще, для многих стандартных типов контейнеров были созданы новые специальные классы. Каждый из этих контейнеров просто наследуется от аналога из стандартной библиотеки и имеет префикс «x». 

  • xlist

  • xmap

  • xmultimap

  • xset

  • xmultiset

  • xqueue

Следующий код показывает полную реализацию xlist. Обратите внимание, что xlist просто наследует от std::list, но ключевым отличием является то, что аргумент шаблона _Ax по умолчанию устанавливает stl_allocator, а не std::allocator.

#ifndef _XLIST_H
#define _XLIST_H

#include "stl_allocator.h"
#include 

template >
class xlist : public std::list<_Ty, _Ax>
{
};

#endif

Каждая из «x»-версий STL-контейнеров используется так же, как и std-версия, за исключением того, что все аллокации обрабатываются stl_allocator. Например:

#include  "xlist.h"

xlist myList;
myList.push_back("hello world");

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

  • std::string

  • std::vector

Соответствующий xvector не был реализован из соображений безопасности, потому что векторы требуют смежной области памяти, в то время как другие типы контейнеров работают с узлами. Фиксированный аллокатор прекрасно работает с блоками одинакового размера, но не очень хорошо, если вектор постоянно расширяется в виде одного блока памяти все большего и большего размера. Хотя stl_allocator и может работать с вектором, его можно использовать неправильно и вызвать проблемы с менеджером памяти.

std::string также меняет размер запрашиваемой памяти во время выполнения, но обычно строки не используются неограниченно. То есть, в большинстве случаев вам не нужно хранить строку размером 100 Кбайт, в то время как с вектором такое вполне может быть. Поэтому мы можем использовать xstring, как объясняется далее.

«x»-строки

Были созданы новые «x»-версии стандартной и расширенной версий класса string.

  • xstring

  • xwstring

Здесь мы просто делаем typedef новой «x»-версии, используя stl_allocator в качестве аллокатора по умолчанию для типов char и wchar_t:

#ifndef _XSTRING_H
#define _XSTRING_H

#include "stl_allocator.h"
#include 

typedef std::basic_string, stl_allocator > xstring;
typedef std::basic_string, stl_allocator > xwstring;

#endif

Использование xstring выглядит так же, как и у любой другой std::string

#include "xstring.h"

xwstring s = L"This is ";
s += L"my string.";

«x»-потоки

Стандартная библиотека C++ iostreams предлагает простой в использовании и функциональный способ форматирования строк с помощью stream-классов.

  • std::stringstream

  • std::ostringstream

  • std::wstringstream

  • std::wostringstream

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

  • xstringstream

  • xostringstream

  • xwstringstream

  • xwostringstream

Опять же, это просто typedef новых «x»-версий с аргументами шаблона по умолчанию stl_allocator:

#ifndef _XSSTREAM_H
#define _XSSTREAM_H

#include "stl_allocator.h"
#include 

typedef std::basic_stringstream, 
stl_allocator > xstringstream;
typedef std::basic_ostringstream, 
stl_allocator > xostringstream;

typedef std::basic_stringstream, 
stl_allocator > xwstringstream;
typedef std::basic_ostringstream, 
stl_allocator > xwostringstream;

#endif

И использование xstringstream не отличается от оригинала:

xstringstream myStringStream;
myStringStream << "hello world " << 2016 << ends;

Бенчмаркинг

В моих предыдущих статьях об аллокаторах простые тесты показали, что фиксированный аллокатор работает быстрее, чем глобальная куча. Теперь давайте проверим контейнеры с поддержкой stl_allocator и посмотрим, насколько они превосходят std::allocator на ПК с Windows. 

Все тесты были собраны в Visual Studio 2008 с включенной максимальной оптимизацией компилятора. xallocator работает в режиме блоков кучи (heap blocks), в котором блоки изначально берутся из кучи, но перерабатываются при деаллокации. Отладочная куча также отключена путем установки параметра Debugging > Environment _NO_DEBUG_HEAP=1. Отладочная куча работает значительно медленнее из-за дополнительных проверок безопасности. 

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

Приведенный ниже фрагмент кода — это тест std::list. Остальные тесты контейнеров аналогичны. 

list myList;
for (int i=0; i

Разница в производительности между std::allocator и stl_allocator, работающими в режиме выбора блоков из кучи, показана ниже:  

Контейнер

Режим

Запуск

Контрольное время (мс)

std: list

Глобальная куча

1

60

std: list

Глобальная куча

2

32

std: list

Глобальная куча

3

23

xlist

Блоки из кучи

1

19

xlist

Блоки из кучи

2

11

xlist

Блоки из кучи

3

11

std: map

Глобальная куча

1

37

std: map

Глобальная куча

2

34

std: map

Глобальная куча

3

41

xmap

Блоки из кучи

1

38

xmap

Блоки из кучи

2

25

xmap

Блоки из кучи

3

25

std: string

Глобальная куча

1

171

std: string

Глобальная куча

2

146

std: string

Глобальная куча

3

95

xstring

Блоки из кучи

1

57

xstring

Блоки из кучи

2

36

xstring

Блоки из кучи

3

40

Результаты теста показывают, что для этого бенчмарка версии с поддержкой stl_allocator примерно в 2–3 раза быстрее, чем std::allocator. Но это не означает, что STL стал волшебным образом в два-три раза быстрее. Опять же, этот бенчмарк концентрируется только на производительности вставки и удаления. Базовая алгоритмическая производительность контейнера не изменилась. Поэтому общий прирост производительности будет зависеть от того, как часто ваше приложение вставляет/удаляет контейнеры. 

Если xallocator используется в режиме статического пула (static pool), stl_allocator работает за постоянное время. Если xallocator работает в режиме выбора блоков из кучи, время выполнения становится постоянным после заполнения списка свободных блоков, полученными из кучи. Обратите внимание, что в приведенном выше бенчмарке xlist начальное время выполнения составляет 19 мс, а последующие выполняются по 11 мс. В первом запуске xallocator должен был получить свежие блоки из глобальной кучи с помощью оператора new. Во втором и третьем запусках блоки уже существовали в списке свободных блоков xallocator, поэтому куча не используется, что значительно ускоряет последующие запуски. 

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

Разработчики игр прилагают огромные усилия для решения проблемы фрагментации кучи и множества связанных с ней проблем. В техническом документе Electronic Arts Standard Template Library (EASTL) подробно рассматриваются проблемы STL и, в частности, std::allocator. Пол утверждает следующее в документе EASTL — Electronic Arts Standard Template Library:

Среди разработчиков игр наиболее фундаментальным недостатком считается архитектура std-аллокатора, и именно этот недостаток был самым большим фактором, способствовавшим созданию EASTL.

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

Справочные статьи

Преимущества

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

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

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

Ревизии

  • 3.04.2016

  • 11.04.2016

  • 14.04.2017

    • Исправлена незначительная ошибка включения компилятора в системах Linux

    • Прикреплен обновленный исходный код

  • 21.01.2024

Лицензия

Эта статья, а также весь, связанный с ней исходный код и файлы, лицензированы под Code Project Open License (CPOL)

15 февраля в OTUS в раках курса C++ Developer. Professional пройдет открытый урок, на котором разебрём, как работает динамическое выделение памяти на этапе компиляции в C++20, а также зачем это нужно и где можно использовать. Участие бесплатное, записаться можно по ссылке.

© Habrahabr.ru