Вы хотите идти в ногу со временем и перевести ваш проект на полиморфные аллокаторы? Вас не могут отговорить от этого даже затраты на виртуальные вызовы? Тогда вы просто обязаны знать о нюансах с лайфтаймом, и почему нельзя просто взять и поменять свои контейнеры на аналоги из пространства имён pmr.
Давайте предположим для примера, что вы работаете в биологической лаборатории. Вам поставили задачу разработать приложение, которое позволяло бы симулировать жизненный цикл каких-нибудь бактерий. Соответственно, у вас в проекте есть класс, описывающий бактерию. При этом каждой бактерии принадлежит набор генов. Это могло бы выглядеть следующим образом:
class Bacteria
{
private:
using gene_type = TheGene;
using genes_container = std::vector<gene_type>;
private:
static genes_container RandomGenes();
public:
Bacteria() = default;
Bacteria(const Bacteria&) = default;
Bacteria(Bacteria&&) = default;
Bacteria& operator = (const Bacteria&) = default;
Bacteria& operator = (Bacteria&&) = default;
~Bacteria() = default;
public:
void MutateRandomGene();
Bacteria Clone() const;
/* something else ... */
private:
genes_container m_genes = RandomGenes();
};
Ваше приложение работает, бактерии симулируются, но как-то всё тормозит. В процессе поисков вы дошли до класса Bacteria
, посмотрели на эту вершину IT-искусства и решили, что он может стать узким местом. Но почему?
Всё дело в том, что бактерии постоянно делятся и умирают. Их тысячи и миллионы. И у каждой такой бактерии есть свой контейнер со своими генами. Каждый раз, когда вы создаёте бактерию с набором генов, std::vector
обращается к std::allocator
с просьбой выделить под них память. Стандартный аллокатор, в свою очередь, при помощи оператора new
стучится в систему и спрашивает у неё разрешения отщипнуть кусочек свободной памяти. Это медленно. Гораздо медленнее, чем обращение к памяти, уже принадлежащей процессу.
Более того, когда бактерия умирает, с таким трудом добытая память "убегает" обратно в систему, потому что стандартный аллокатор использует оператор delete
. А ведь эту память можно было бы использовать для хранения данных какой-нибудь новой бактерии. Но вместо этого стандартному аллокатору придётся вновь идти на поклон к системе и просить очередной кусочек оперативной памяти...
Эта проблема известна уже давно, и уже давно придумали множество разных аллокаторов. Неплохую статью про них можно почитать здесь. Но всех их нужно либо реализовывать самостоятельно, либо затягивать third-party библиотеки. А это не всегда самый лучший вариант. Вот бы язык "из-под коробки" поддерживал что-нибудь подобное...
И разработчиков услышали! Начиная с С++17, вам становятся доступны полиморфные аллокаторы. Их идея немного отличается от того, что нам нужно. Но для нас важно, что они позволяют указать буфер, откуда аллокатор будет брать память. Такой буфер называется ресурсом и должен наследоваться от std::memory_resource
. Также для нас важно, что С++17 вводит несколько стандартных ресурсов. В качестве примера мы будем рассматривать работу с std::unsynchronized_pool_resource
, но всё, что будет в этой статье, по большей части относится ко всем ресурсам в стандартной библиотеке.
Статический пул — не слишком хорошо
В std::unsynchronized_pool_resource
, если размер аллокации не превышает максимальный объём, память освобождается и возвращается в систему только при вызове деструктора этого ресурса. До этого момента освобождаемая контейнером память попадает обратно в пул, а не в систему. То есть вся память, пока пул "жив", остаётся принадлежать процессу. Следовательно, её можно будет переиспользовать, не стучась в систему.
"Это же именно то, что нужно!" — думаете вы и лёгкой рукой меняете контейнер с генами на std::pmr::vector
. Проблем это не вызывает, так как вы заранее обмазали свои классы псевдонимами. Также вам нужно теперь где-то хранить пул, общий для ваших бактерий. И вы добавляете статическую функцию, возвращающую ваш пул. Теперь класс выглядит так:
class Bacteria
{
private:
using gene_type = TheGene;
using genes_container = std::pmr::vector<gene_type>;
using pool_type = std::pmr::unsynchronized_pool_resource;
private:
static genes_container RandomGenes();
static pool_type& GetPool();
public:
Bacteria() = default;
Bacteria(const Bacteria&) = default;
Bacteria(Bacteria&&) = default;
Bacteria& operator = (const Bacteria&) = default;
Bacteria& operator = (Bacteria&&) = default;
~Bacteria() = default;
public:
void MutateRandomGene();
Bacteria Clone() const;
/* something else ... */
private:
genes_container m_genes = RandomGenes();
};
Облизнувшись, вы накидали бенчмарки, запустили и... И что-то результат не впечатляет.
Здесь на сцену выходит первый нюанс работы с полиморфными аллокаторами. Дело в том, что при использовании полиморфных аллокаторов поведение конструкторов и операторов копирования/перемещения начинает неочевидно отличаться от того, к чему мы привыкли.
При вызове конструктора копирования вектора конструируемый объект получает аллокатор через вызов функции select_on_container_copy_construction
. Стандартный std::allocator
не имеет такой функции, поэтому у конструируемого объекта аллокатор равен аллокатору копируемого объекта. Но вот у std::pmr::polymorphic_allocator
такая функция есть. В итоге вместо того, чтобы забрать аллокатор у копируемого объекта, конструируемый объект получает аллокатор по умолчанию. Если вы его не меняли при помощи функции std::pmr::set_default_resource
, то он будет точно так же использовать операции new
и delete
вместо обращения к общему пулу памяти. И мы снова начинаем давить на педаль тормоза...
Ладно, не проблема. Нужно, чтобы скопированная бактерия использовала тот же пул памяти, что и копируемая. Для этого явно определим конструктор копирования и укажем, что нужно использовать аллокатор у реципиента:
Bacteria::Bacteria(const Bacteria &other)
: m_genes { other.m_genes, other.m_genes.get_allocator() }
{}
При этом с конструктором перемещения такой проблемы нет, так как std::vector
возьмёт аллокатор сразу из копируемого объекта. Не знаю, почему так сделали, но выглядит это максимально неочевидно и неудобно. Напишите в комментариях, что вы думаете по этому поводу.
В любом случае, вы снова идёте в бенчмарки и... Ооо да, стало гораздо лучше. Но было бы слишком просто, если бы на этом всё закончилось. Когда вы используете полиморфный аллокатор, вы обязаны гарантировать, что деструктор ресурса не вызовется раньше деструктора последнего объекта, который его использует.
Например, вы решили сделать класс, описывающий инкубатор для бактерий:
template <typename B>
class Incubator
{
private:
using bacteria_type = B;
using bacteries_container = std::deque<bacteria_type>;
private:
Incubator() = default;
Incubator(const Incubator&) = default;
Incubator(Incubator&&) = default;
Incubator& operator = (const Incubator&) = default;
Incubator& operator = (Incubator&&) = default;
public:
~Incubator();
public:
void AddBacteria(bacteria_type bacteria);
/* something useful */
public:
bacteries_container bacteries;
};
Пусть он тоже будет статический:
Incubator<Bacteria>& GetBacteriaIncubator()
{
static Incubator<Bacteria> incubator;
return incubator;
}
Ловушка здесь простая, но неочевидная для стороннего пользователя. Программист, использующий вашу библиотеку/классы, скорее всего, напишет что-то вроде этого:
auto &&incubator = GetBacteriaIncubator();
incubator.AddBacteria({});
И получит UB. Это может стать большой неожиданностью для того, кто не знает детали реализации класса бактерии. Мы привыкли, что при использовании стандартного аллокатора можно не особо заботиться о времени жизни переменной. При её создании память выделится, при уничтожении — освободится. Но с полиморфными аллокаторами мы снова должны следить за тем, чтобы время жизни наших объектов не превысило время жизни memory_resource
.
В примере выше сначала будет проинициализирован класс инкубатора, а уже потом пул для бактерий. Так как пул и инкубатор имеют static storage duration, их удаление будет происходить в порядке, обратном созданию. И деструктор пула вызовется раньше, чем деструктор инкубатора. В итоге, если в инкубаторе будут бактерии, при попытке освободить память они обратятся к полиморфному аллокатору, у которого будет "висячий" указатель на пул.
Владеть пулом — слишком нехорошо
Предположим, что нас теперь не устраивает статический пул. Тогда пусть бактерии сами владеют ресурсом! Пока жива хоть одна бактерия, пул будет жить. А потом, когда все бактерии уничтожатся, умрёт и сам пул. Заодно и память освободим, а не будем держать бесполезные куски до завершения программы.
Давайте попробуем добавить бактериям поле std::shared_ptr
, которое ссылается на ресурс для аллокатора. Так они будут владеть одним общим пулом, который удалится, когда ни одной бактерии не останется. Возможно, опытному программисту уже на этом этапе придёт в голову мысль, что мы что-то делаем не так... Но не будем забегать вперёд. Теперь наша бактерия выглядит примерно так:
class Bacteria
{
private:
using gene_type = TheGene;
using genes_container = std::pmr::vector<gene_type>;
using pool_type = std::pmr::unsynchronized_pool_resource;
private:
static genes_container RandomGenes(pool_type &pool);
public:
Bacteria();
Bacteria(const Bacteria&);
Bacteria(Bacteria&&) = default;
Bacteria& operator = (const Bacteria&) = default;
Bacteria& operator = (Bacteria&&) = default;
~Bacteria() = default;
public:
void MutateRandomGene();
Bacteria Clone() const;
/* something else ... */
private:
std::shared_ptr<pool_type> m_pool;
genes_container m_genes;
};
Не забываем про конструкторы:
Bacteria::Bacteria()
: m_pool(std::make_shared<pool_type>())
, m_genes(RandomGenes(*m_pool))
{}
Bacteria::Bacteria(const Bacteria &other)
: m_pool(other.m_pool)
, m_genes(other.m_genes, other.m_genes.get_allocator())
{}
Что ж, вроде всё работает: аллокации быстрые, память освободится, когда необходимо, — что ещё нужно для счастья? А давайте теперь попробуем сделать не только размножение бактерий, но и возможность добавить новый патоген:
if (RandomDie() && bacteries.size() > 1)
{
bacteries.erase(bacteries.begin() + index);
}
else if (RandomDivision())
{
auto &&bacteria = bacteries[index];
bacteries.push_back(bacteria.Clone());
}
else if (RandomNewBacteria())
{
bacteries.emplace_back();
}
И тут же получим падение и UB. Дело в том, что в нашей реализации новая бактерия, не клонированная от какой-либо другой, будет использовать не общий пул, а новый. В итоге у двух случайных бактерий указатели m_pool
могут ссылаться на разные пулы. Само по себе это ошибки не несёт, если не учитывать, что мы снова неэффективно используем память. Падение возникает из-за висячего указателя на пул.
Как же так? Мы ведь используем умный указатель, который должен гарантировать, что объект жив. Загвоздка кроется в операторах копирования и перемещения, которые вызываются при удалении элемента. Вектор после удаления пытается переместить оставшиеся элементы на один влево. Мы явно указали, что операторы копирования и перемещения должен сгенерировать компилятор. Далее идёт implementation defined, но он сделает нечто похожее на это:
Bacteria& Bacteria::operator = (const Bacteria &other)
{
if (this == &other) { return *this; }
m_pool = other.m_pool;
m_genes = other.m_genes;
return *this;
}
И в этих простых строчках кроется целых две причины UB. Первая: сначала происходит копирование std::shared_ptr
, а затем контейнера, который его использует. Если m_pool
будет единственной сильной ссылкой, то при вызове оператора копирования ресурс удалится. Далее при копировании std::vector
с полиморфным аллокатором в операторе =
произойдёт сравнение аллокаторов с помощью оператора ==
. В свою очередь полиморфный аллокатор вернёт результат сравнения нижележащих ресурсов. Для этого ему придётся разыменовать указатель на пул, который уже умер. То есть произойдёт разыменование висячего указателя. То же самое происходит и в случае оператора перемещения.
Это значит, что поведение операторов копирования и перемещения по умолчанию нам не подходят. Реализуем свою версию, где пул не может удалиться, пока контейнер с ним работает:
Bacteria& Bacteria::operator = (const Bacteria &other)
{
if (this == &other) { return *this; }
m_genes = other.m_genes;
m_pool = other.m_pool;
return *this;
}
Но, как я и сказал, у нас есть второе UB. В начале статьи я писал, что поведение операторов копирования и перемещения у контейнеров с полиморфным аллокатором отличается от того, к которому мы привыкли.
В нашем случае полиморфный аллокатор при копировании контейнера не копируется из other.m_genes
, а остаётся прежний. То есть даже после вызова оператора копирования в m_genes
останется старый аллокатор с указателем на старый пул. И может произойти та же ситуация, что и до этого: пул удалится, а в аллокаторе останется висячий указатель на ресурс. Избежать этого можно только явной передачей нового аллокатора.
Но при обычном присвоении нельзя явно указать аллокатор, который следует использовать. А значит, остаётся только вызов конструктора:
Bacteria& Bacteria::operator = (const Bacteria &other)
{
if (this == &other) { return *this; }
m_genes.~genes_container();
new (&m_genes) genes_container(other.m_genes,
other.m_genes.get_allocator());
m_pool = other.m_pool;
return *this;
}
Но тут тоже всё непросто. После вызова деструктора мы вызываем конструктор копирования, который может бросить исключение. В этом случае исключение полетит дальше и в процессе раскрутки стека может вызвать деструктор у *this
, что приведёт к повторному вызову деструктора m_genes
. А это снова UB. Даже если мы обернём код в конструкцию try
, в случае выброса исключения m_genes
будет не проинициализирован. И мы снова повторно вызовем его деструктор. А значит, нужно хитрить:
Bacteria& Bacteria::operator = (const Bacteria &other)
{
if (this == &other) { return *this; }
auto copy = other;
this->~Bacteria();
new (this) Bacteria(std::move(copy));
return *this;
}
Обычно, если вы используете полиморфный аллокатор, ваш конструктор и оператор перемещения теряют noexcept
. Это происходит из-за того, что аллокаторы могут указывать на разные ресурсы. И тогда потенциально могут произойти выделения памяти. Например, с помощью оператора new
. А раз есть выделения памяти, значит, потенциально может быть выброшено исключение, например, std::bad_alloc
.
Чтобы магия начала работать, нужно также определить конструктор перемещения у нашей бактерии:
Bacteria::Bacteria(Bacteria &&other) noexcept
: m_pool(std::move(other.m_pool))
, m_genes(std::move(other.m_genes), other.m_genes.get_allocator())
{}
Теперь m_genes
забирает себе тот же аллокатор, что у other.m_genes
. Поэтому никаких выделений памяти не произойдёт, и мы не получим внезапно вылетевшее исключение.
Ну и грязюка... Зато теперь работает.
Менеджер ресурсов — лучшее из худшего
Писать код, как в примерах выше, дело неблагодарное. Но при использовании полиморфных аллокаторов, если приложение претендует на хоть какую-то работоспособность, мы обязаны гарантировать достаточное время жизни ресурсов. И это главное отличие полиморфных аллокаторов от аллокатора по умолчанию.
На мой взгляд, на 100% безопасного решения не существует. Но кое-что всё-таки можно сделать. Давайте добавим менеджер ресурсов — некоторую сущность, которая будет управлять вашими ресурсами. Её нужно будет создать в самом начале программы, и все ресурсы использовать только через неё. При таком подходе все ресурсы будут в одном месте, а шанс утечки объектов в какое-то место с большим временем жизни стремится к нулю.
Самая простая реализация могла бы выглядеть так:
class ResourceManager
{
private:
using genes_resource_type = std::pmr::unsynchronized_pool_resource;
using other_resource_type = ....;
public:
static void Init();
static ResourceManager& Get();
private:
ResourceManager() = default;
public:
ResourceManager(const ResourceManager&) = delete;
ResourceManager(ResourceManager&&) = delete;
ResourceManager& operator = (const ResourceManager&) = delete;
ResourceManager& operator = (ResourceManager&&) = delete;
~ResourceManager() = default;
public:
genes_resource_type& GetGenesResource();
other_resource_type& GetOtherResource();
private:
genes_resource_type m_genesResource;
other_resource_type m_otherResource;
/* and etc. */
};
Если проинициализировать такой менеджер при старте программы и использовать ресурсы только из него, то, вероятно, всё будет в порядке. Да, придётся дописаться в начало функции main, но зато меньше вероятность словить UB. С предыдущими примерами это бы выглядело так.
Да, полностью защититься не получится. Кто-нибудь может зайти в проект и дописаться перед инициализацией ресурсов. Например, так. Но тут уже ничего не сделать.
Реализация менеджера в примере выше самая простая. При желании менеджер ресурсов можно (нужно) улучшить: сделать ему автоматическую очистку памяти, возможность динамически выделять новые пулы и брать их по индексу или тегу. Много чего можно придумать.
Но главный посыл этой статьи: нельзя просто взять и поменять ваши стандартные аллокаторы на pmr. Это нужно делать с умом, помня про возможные неожиданности вроде времени жизни.
Если хотите поделиться этой статьей с англоязычной аудиторией, то прошу использовать ссылку на перевод: Grigory Semenchev. How frivolous use of polymorphic allocators can imbitter your life.
Комментарии (2)
Salabar
23.01.2025 10:39А в чем смысл деструкторов, если памятью управляет другой объект? Перешли на арены, ну и молодцы. Модно, молодежно, безопасно. И тут мы поверх этой радости зачем-то добавляем RAII.
Когда я хочу себе сделать больно, я просто достаю бритву.
dersoverflow
мой бог, Чудовищное(tm) зрелище!!! вот за это ненавидят и боятся C++.
уже давно писал и объяснял: переходите на sh_ptr/mem_pool и получите МНОГОКРАТНОЕ ускорение без мути https://ders.by/cpp/norefs/norefs.html#4.3
ЗЫ плюс научитесь различать Чудовищное...