Кто из нас не любит рефакторинг? Думаю, что неоднократно каждый из нас при рефакторинге старого кода открывал для себя что-то новое или вспоминал что-то важное, но хорошо забытое. Совсем недавно, несколько освежив свои знания работы std::shared_ptr при использовании пользовательского аллокатора, я решил что больше забывать их не стоит. Всё что удалось освежилось собрал в этой статье.


В одном из проектов потребовалось провести оптимизацию производительности. Профилирование указало на большое количество вызовов операторов new/delete и соответствующих вызовов malloc/free, которые не только приводят к дорогим блокировкам в многопоточной среде сами по себе, но и могут вызывать такие тяжелые функций как malloc_consolidate в самый неожиданный момент. Большое количество операций с динамической памятью было обусловлено интенсивной работой с умными указателями std::shared_ptr.


Классов, объекты которых создавались указанным образом, оказалось не много. Кроме того, не хотелось сильно переписывать приложение. Поэтому было принято решение исследовать возможность использования паттерна — object pool. Т.е. оставить использование shared_ptr, но переделать механизм выделения памяти таким образом, чтобы избавиться от интенсивного получения/освобождения динамической памяти.


Замену стандартной реализации malloc на другие варианты(tcmalloc, jemalloc) не рассматривали, т.к. по опыту замена стандартной реализации принципиально на производительность не влияла, а вот изменения коснулись бы всей программы с возможными последствиями.


В дальнейшем идея трансформировалась в использование собственного пула памяти и реализацию специального аллокатора. Преимуществом использования memory pool в моем случае перед object pool — прозрачность для вызывающего кода. При использовании аллокатора объекты будут размещаться в уже выделенной памяти(будет использоваться размещающий оператор new) с соответствующим вызовом конструктора, а так же очищаться явным вызовов деструктора. Т.е. дополнительных действий, которые характерны для object pool, для инициализации объекта(при получении из пула) и для приведения его в исходное состояние(перед возвращением в пул) выполнять не требуется.


Далее я рассмотрю какие интересные особенности работы с памятью при использовании shared_ptr лично я для себя уяснил и разложил по полочкам. Чтобы не перегружать текст деталями, код будет упрощенными и к реальному проекту будет относиться только в самых общих чертах. В первую очередь я буду фокусироваться не на реализации аллокатора, а на принципе работы с std::shared_ptr при ипользовании кастомного алокатора.


Текущим механизмом создания указателя было использование std::make_shared:


auto ptr = std::make_shared<foo_struct>();

Как известно, этот способ создания указателя избавляет от некоторых потенциальных проблем, связанных с утечкой памяти, имеющих место, если создавать указатель по рабоче-крестьянски(хотя в некоторых случаях и такой вариант обоснован. Например, если нужно передать deleter):


auto ptr = std::shared_ptr<foo_struct>(new foo_struct);

Ключевая идея в работе с памятью std::shared_ptr в порядке создания управляющего блока. А мы знаем, что это специальная структура, которая и делает указатель умным. И для неё нужно соответственно честно выделить память.


Возможность полностью контролировать использование памяти при работе с std::shared_ptr нам предоставляется через std::allocate_shared. При вызове std::allocate_shared можно передать собственный аллокатор:


auto ptr = std::allocate_shared<foo_struct>(allocator);

Если переопределить операторы new и delete, то можно посмотреть как происходит выделение нужного объема памяти для структуры из примера:



struct foo_struct
{
    foo_struct()
    {
        std::cout << "foo_struct()" << std::endl;
    }

    ~foo_struct()
    {
        std::cout << "~foo_struct()" << std::endl;
    }

    uint64_t value1 = 1;
    uint64_t value2 = 2;
    uint64_t value3 = 3;
    uint64_t value4 = 4;
};

Возьмем для примера простейший аллокатор:


template <class T>
struct custom_allocator {
    typedef T value_type;
    custom_allocator() noexcept {}
    template <class U> custom_allocator (const custom_allocator<U>&) noexcept {}
    T* allocate (std::size_t n) {
        return reinterpret_cast<T*>( ::operator new(n*sizeof(T)));
    }
    void deallocate (T* p, std::size_t n) {
        ::operator delete(p);
    }

};

Посмотреть
---- Construct shared ----
operator new: size = 32 p = 0x1742030
foo_struct()
operator new: size = 24 p = 0x1742060
~foo_struct()
operator delete: p = 0x1742030
operator delete: p = 0x1742060
---- Construct shared ----

---- Make shared ----
operator new: size = 48 p = 0x1742080
foo_struct()
~foo_struct()
operator delete: p = 0x1742080
---- Make shared ----

---- Allocate shared ----
operator new: size = 48 p = 0x1742080
foo_struct()
~foo_struct()
operator delete: p = 0x1742080
---- Allocate shared ----

Важной особенностью использования как std::make_shared, так и кастомного аллокатора при работе с shared_ptr является, на первый взгляд незначительная штука, возможность выделения памяти как для самого объекта, так и для управляющего блока за один вызов аллокатора. Об этом часто пишут в книжках, но это слабо откладывается в памяти до момента пока с этим не столкнешься на практике.


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


Добавив немного отладочного вывода в работу аллокатора в этом можно убедиться
---- Allocate shared ----
Allocating: std::_Sp_counted_ptr_inplace<foo_struct, custom_allocator<foo_struct>, (__gnu_cxx::_Lock_policy)2>
operator new: size = 48 p = 0x1742080
foo_struct()
~foo_struct()
Deallocating: std::_Sp_counted_ptr_inplace<foo_struct, custom_allocator<foo_struct>, (__gnu_cxx::_Lock_policy)2>
operator delete: p = 0x1742080
---- Allocate shared ----

Память выделяется не для объекта класса foo_struct. Точнее говоря, не только для foo_struct.


Всё становится на свои места, когда мы вспоминаем про управляющий блок std::shared_ptr. Теперь, если добавить ещё немного отладочного вывода в конструктор копирования аллокатора, то можно увидеть тип создаваемого объекта.


Увидеть
---- Allocate shared ----
sizeof control_block_type: 48
sizeof foo_struct: 32
custom_allocator<T>::custom_allocator(const custom_allocator<U>&): 
    T: std::_Sp_counted_ptr_inplace<foo_struct, custom_allocator<foo_struct>, (__gnu_cxx::_Lock_policy)2>
    U: foo_struct
Allocating: std::_Sp_counted_ptr_inplace<foo_struct, custom_allocator<foo_struct>, (__gnu_cxx::_Lock_policy)2>
operator new: size = 48 p = 0x1742080
foo_struct()
~foo_struct()
custom_allocator<T>::custom_allocator(const custom_allocator<U>&): 
    T: std::_Sp_counted_ptr_inplace<foo_struct, custom_allocator<foo_struct>, (__gnu_cxx::_Lock_policy)2>
    U: foo_struct
Deallocating: std::_Sp_counted_ptr_inplace<foo_struct, custom_allocator<foo_struct>, (__gnu_cxx::_Lock_policy)2>
operator delete: p = 0x1742080
---- Allocate shared ----

В данном случае срабатывает allocator rebind. Т.е. получение аллокатора одного типа из аллокатора другого типа. Этот "трюк" используется не только в std::shared_ptr, но и в других классах стандартной библиотеки таких как std::list или std::map — там где реально хранимый объект отличается от пользовательского. При этом из исходного аллокатора создается нужный вариант для выделения требуемого объема памяти.


Итак, при использовании кастомного аллокатора память выделяется как для управляющего блока, так и для самого объекта. И всё это за один вызов. Это следует учитывать при создании аллокатора. Особенно, если используется память предварительно выделенная блоками фиксированной длины. Проблема тут заключается в том, чтобы правильно определить блок памяти какого размера будет реально необходим при работе аллокатора.


Определение размера блока памяти

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


using control_block_type = std::_Sp_counted_ptr_inplace<foo_struct, custom_allocator<foo_struct>, (__gnu_cxx::_Lock_policy)2>;
constexpr static size_t block_size = sizeof(control_block_type);

Кстати, в зависимости от версии компилятора размер управляющего блок различается.


Буду благодарен за подсказку как решить эту задачку более элегантным способом.


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


Исходный код примера на гитхабе.


Спасибо за внимание!

Поделиться с друзьями
-->

Комментарии (9)


  1. maaGames
    28.06.2016 15:09
    +3

    Реквестирую табличку с замерами производительности «до» и «после». Иначе не понятно, стоила ли игра свеч (вдруг проблема была в другом?)


    1. pirog-spb
      28.06.2016 23:25

      maaGames Думаю, что в рамках данной статьи вам придется поверить мне на слово. Тема всё-таки была в освещении базовых идей на отстраненном примере. Реализация аллокатора — простейшая.
      Но, я согласен, что было бы неплохо иметь представление о применении аллокатора на примере более приближенном к действительности. И с замерами. Обязуюсь исправиться в очередной статье.


  1. Anton3
    28.06.2016 16:06

    Как вариант, использовать кастомный аллокатор с std::unique_ptr.
    С помощью шаблонных using pool_ptr и make_pool этот вариант тоже можно прихорошить, по удобству будет во многом не хуже shared_ptr.
    Когда будете делать замеры производительности, рассмотрите и этот вариант тоже ;)

    Переходя в раздел «ненормальное программирование», можно предложить вообще обходиться без указателей. Иерархию наследования преобразовывать в boost::variant. На практике, конечно, я бы не советовал везде пихать такие «извращения».


    1. dbagaev
      28.06.2016 17:59

      Универсальный вариант — это правильно написанный allocator rebind и продуманная организация пулов по типам и размерам объектов и контекстам их использования. Дело в том, что rebind используется также при работе с различными контейнерами, поэтому передача нужного аллокатора куда надо также может существенно повлять на производительность. При этом непродуманная политика аллоцирования приводит к потреблению большого количества памяти.

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


      1. monah_tuk
        04.07.2016 06:33

        Зачем копировать? Перемещать!


        1. Anton3
          04.07.2016 20:00

          Если строить все структуры данных без динамического выделения памяти, то перемещение займет столько же времени, сколько копирование. То есть ситуация тут напоминает std::array: вроде, быстрее работает, но пользоваться неудобно.


  1. arteast
    28.06.2016 18:04
    +1

    Вроде бы в C++17 хотят добавить возможность определить размеры управляющей структуры для STL структур — если сделают, то будет портабельный способ использовать пул для shared_ptr/list/map и т.д.
    Для данного описанного случая (особенно если weak_ptr для этого типа объектов не нужен) может оказаться лучшим выходом переделка shared_ptr на intrusive_ptr.


    1. Anton3
      28.06.2016 18:42
      +1

      А можете описать, чем intrusive_ptr лучше, чем shared_ptr/make_shared?


      1. pirog-spb
        28.06.2016 23:33
        +1

        При использовании intrusive_ptr реализация самого механизма подсчета ссылок ложится на класс объекта. Со всеми вытекающими расходами на хранение дополнительных данных(счетчиков). Сам intrusive_ptr просто уведомляет о необходимости уменьшить или увеличить счетчик. Ну и понимает когда нужно удаляться.
        В этом случае нет надобности хранить что-то верх самого объекта. А размер объекта может быть легко вычислен через sizeof.