Кто из нас не любит рефакторинг? Думаю, что неоднократно каждый из нас при рефакторинге старого кода открывал для себя что-то новое или вспоминал что-то важное, но хорошо забытое. Совсем недавно, несколько освежив свои знания работы 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);
Кстати, в зависимости от версии компилятора размер управляющего блок различается.
Буду благодарен за подсказку как решить эту задачку более элегантным способом.
В качестве заключения хотел бы повторить, что важным результатом использования альтернативного аллокатора стала возможность без серьезной модификации существующего кода и интерфейса работы с объектами выполнить оптимизацию. Ну и конечно, не забывайте периодически освежать в памяти разные тонкие аспекты работы вашего языка программирования!
Исходный код примера на гитхабе.
Спасибо за внимание!
Автор: pirog-spb