std::shared_ptr і кастомный аллокатор

Хто з нас не любить рефакторинг? Думаю, що неодноразово кожен з нас при рефакторинге старого коду відкривав для себе щось нове або згадував щось важливе, але добре забуте. Зовсім недавно, кілька освіживши свої знання роботи 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);

до Речі, в залежності від версії компілятора розмір керуючого блок розрізняється.
Буду вдячний за підказку як вирішити цю задачку більш елегантним способом.
В якості висновку хотів би повторити, що важливим результатом використання альтернативного аллокатора стала можливість без серйозної модифікації існуючого коду і інтерфейсу роботи з об'єктами виконати оптимізацію. Ну і звичайно, не забувайте періодично освіжати в пам'яті різні тонкі аспекти роботи вашого мови програмування!
Вихідний код прикладу на гітхабі.
Дякую за увагу!
Джерело: Хабрахабр
  • avatar
  • 0

0 коментарів

Тільки зареєстровані та авторизовані користувачі можуть залишати коментарі.