C++названные требования:Распределитель
Инкапсулирует стратегии доступа/адресации,распределения/делегации и строительства/разрушения объектов.
Каждый компонент стандартной библиотеки, которому может потребоваться выделить или освободить хранилище, от std::string , std::vector и каждого контейнера, кроме std::array , до std::shared_ptr и std::function , делает это через Allocator : объект типа класса, который удовлетворяет следующим требованиям.
Реализация многих требований к распределителям не является обязательной, поскольку все классы, поддерживающие распределители, включая контейнеры стандартных библиотек, обращаются к распределителям косвенно через std::allocator_traits , а std::allocator_traits обеспечивает реализацию этих требований по умолчанию.
Requirements
- T , cv-неквалифицированный тип объекта
- A , тип распределителя для типа T
- a , объект типа A
- B , соответствующий тип Allocator для некоторого cv-неквалифицированного типа объекта U (как получено путем связывания A )
- b , объект типа B
- p , значение типа allocator_traits<A>::pointer , полученное вызовом allocator_traits<A>::allocate()
- cp , значение типа allocator_traits<A>::const_pointer , полученное преобразованием из p
- vp , значение типа allocator_traits<A>::void_pointer , полученное преобразованием из p
- cvp , значение типа allocator_traits<A>::const_void_pointer , полученное преобразованием из cp или из vp
- xp , разыменовываемый указатель на некоторый cv-неквалифицированный объект типа X
- r , lvalue типа T , полученное выражением *p
- n , значение типа allocator_traits<A>::size_type
- Удовлетворяет NullablePointer , LegacyRandomAccessIterator и LegacyContiguousIterator .
- Удовлетворяет NullablePointer , LegacyRandomAccessIterator и LegacyContiguousIterator .
- A::pointer можно преобразовать в A::const_pointer .
- Satisfies NullablePointer.
- A::pointer можно преобразовать в A::void_pointer .
- B::void_pointer и A::void_pointer относятся к одному типу.
- Satisfies NullablePointer.
- A::pointer , A::const_pointer и A::void_pointer можно преобразовать в A::const_void_pointer .
- B::const_void_pointer и A::const_void_pointer относятся к одному типу.
- Беззнаковый целочисленный тип.
- Может представлять размер самого большого объекта , который A может выделить.
- Знаковый целочисленный тип.
- Может представлять разницу любых двух указателей на объекты, выделенные A .
- Для любого U , B::template rebind<T>::other есть A .
Expression | Return type | Requirements |
---|---|---|
*p | T& | |
*cp | const T& | *cp и *p обозначают один и тот же объект. |
p->m | (as is) | То же, что (*p).m , если (*p).m корректно определен. |
cp->m | (as is) | То же, что (*cp).m , если (*cp).m корректно определен. |
static_cast<A::pointer>(vp) | (as is) | static_cast<A::pointer>(vp) == p |
static_cast<A::const_pointer>(cvp) | (as is) | static_cast<A::const_pointer>(cvp) == cp |
std::pointer_traits<A::pointer>::pointer_to(r) | (as is) |
Expression | Return type | Requirements |
---|---|---|
a.allocate(n) | A::pointer | Выделяет память, подходящую для объекта массива типа T[n] , и создает массив, но не создает элементы массива. Может вызывать исключения. |
a.allocate(n, cvp) (optional) | То же, что и a.allocate(n) , но может использовать cvp ( nullptr или указатель, полученный из a.allocate() ) неуказанным образом для облегчения локальности. | |
a.allocate_at_least(n) (optional)(since C++23) | std::allocation_result< A::pointer> | Выделяет память, подходящую для объекта массива типа T[cnt] , и создает массив, но не конструирует элементы массива, затем возвращает , где p указывает на память, а cnt не меньше n . Может вызывать исключения. |
a.deallocate(p, n) | (not used) | Освобождает хранилище, указывающее на p , которое должно быть значением, возвращенным предыдущим вызовом allocate или allocate_at_least (начиная с C++23), которое не было отменено промежуточным вызовом deallocate . n должно совпадать со значением, ранее переданным для allocate , или находиться между запросом и возвращаемым числом элементов через allocate_at_least (может быть равно любой границе) (начиная с C++23). Не выдает исключений. |
a.max_size() (optional) | A::size_type | Максимальное значение, которое можно передать в A::allocate() . |
a.construct(xp, args) (optional) | (not used) | Создает объект типа X в ранее выделенном хранилище по адресу, на который указывает xp , используя args в качестве аргументов конструктора. |
a.destroy(xp) (optional) | (not used) | Уничтожает объект типа X , на который указывает xp , но не освобождает память. |
- true , только если память, выделенная распределителем a1 , может быть освобождена через a2 .
- Устанавливает рефлексивные,симметричные и транзитивные отношения.
- Не делает исключений.
- То же, что !(a1==a2) .
- Не делает исключений.
- Не делает исключений.
- Не делает исключений.
- Значение a не изменилось, и a1 == a .
- Не делает исключений.
- true , если любые два распределителя типа A всегда сравниваются как равные.
- (Если не указано, std::allocator_traits по умолчанию std::is_empty<A>::type .)
- Предоставляет экземпляр A для использования контейнером, созданным методом копирования из того, который использует a настоящее время.
- (Обычно возвращает либо копию a ,либо A , созданный по умолчанию .)
- std::true_type или производный от него, если распределитель типа A необходимо скопировать, когда контейнер, который его использует, назначается копированием.
- Если этот член является std::true_type или производным от него, то A должен удовлетворять CopyAssignable и операция копирования не должна вызывать исключений.
- Обратите внимание,что если аллокаторы исходного и целевого контейнеров не равны,то перед копированием элементов (и аллокатора)присвоение копирования должно деаллоцировать память цели,используя старый аллокатор,а затем выделить ее,используя новый аллокатор.
- std::true_type или производный от него, если распределитель типа A необходимо переместить, когда контейнер, который его использует, назначается перемещением.
- Если этот член имеет std::true_type или производный от него, то A должен удовлетворять требованиям MoveAssignable, а операция перемещения не должна вызывать исключений.
- Если этот элемент не предоставлен или получен из std::false_type , а распределители исходного и целевого контейнеров не сравниваются равными, присваивание перемещения не может стать владельцем исходной памяти и должно перемещать-назначать или перемещать-создавать элементы по отдельности, изменение размера собственной памяти по мере необходимости.
- std::true_type или полученный от него, если распределители типа A необходимо поменять местами, когда два контейнера, которые их используют, меняются местами.
- Если этот член является std::true_type или производным от него, lvalues A должен быть Swappable , и операция swap не должна вызывать исключений.
- Если этот элемент не предоставлен или получен из std::false_type , а распределители двух контейнеров не равны, поведение обмена контейнерами не определено.
- См. также причудливые указатели ниже.
- rebind является необязательным (предоставляется std::allocator_traits ), только если этот распределитель является шаблоном формы SomeAllocator<T, Args> , где Args — это ноль или более дополнительных параметров типа шаблона.
- x1 и x2 , объекты (возможно различных) типов X::void_pointer , X::const_void_pointer , X::pointer или X::const_pointer
Затем,х1 и х2 equivalently-valued значения указателя тогда и только тогда, когда и x1 , и x2 могут быть явно преобразованы в два соответствующих объекта px1 и px2 типа X::const_pointer с использованием последовательности static_cast , использующих только эти четыре типа, и выражение px1 == px2 оценивает к истине.
- w1 и w2 , объекты типа X::void_pointer .
Тогда для выражения w1 == w2 и w1 != w2 любой или оба объекта могут быть заменены на equivalently-valued объект типа X::const_void_pointer без изменения семантики.
- p1 и p2 , объекты типа X::pointer
Тогда для выражений p1 == p2 , p1 != p2 , p1 < p2 p1 <= p2 , p1 >= p2 , p1 > p2 , p1 — p2 любой или оба объекта могут быть заменены equivalently-valued объект типа X::const_pointer без изменения семантики.
Вышеуказанные требования позволяют const_iterator iterator Container и const_iterator .
Требования к полноте распределения
Тип распределителя X для типа T дополнительно удовлетворяет требования к полноте распределения если оба из следующих условий являются истинными независимо от того, является ли T полным типом:
- X полный тип
- За исключением value_type , все типы членов std::allocator_traits<X> являются полными типами.
Государственные и нестационарные распределители
Каждый тип аллокатора является либо stateful or stateless . Как правило, тип распределителя с отслеживанием состояния может иметь неравные значения, обозначающие различные ресурсы памяти, в то время как тип распределителя без сохранения состояния обозначает один ресурс памяти.
Хотя пользовательские аллокаторы не обязаны быть stateless,использование stateful аллокаторов в стандартной библиотеке определяется реализацией.Использование неравных значений аллокаторов может привести к ошибкам во время выполнения,определяемым реализацией,или к неопределенному поведению,если реализация не поддерживает такое использование.
Пользовательские распределители могут содержать состояние. Каждый контейнер или другой объект, поддерживающий распределитель, хранит экземпляр предоставленного распределителя и управляет заменой распределителя через std::allocator_traits .
Экземпляры распределителя без сохранения состояния всегда сравниваются как равные. Типы распределителей без сохранения состояния обычно реализуются как пустые классы и подходят для оптимизации пустого базового класса .
Тип члена is_always_equal std::allocator_traits предназначен для определения того, является ли тип распределителя независящим от состояния.
Fancy pointers
pointer типа члена не является необработанным указателем, его обычно называют «причудливым указателем» . Такие указатели были введены для поддержки архитектуры сегментированной памяти и сегодня используются для доступа к объектам, расположенным в адресных пространствах, которые отличаются от однородного виртуального адресного пространства, доступ к которому осуществляется с помощью необработанных указателей. Примером причудливого указателя является независимый от адреса указатель boost::interprocess::offset_ptr , который позволяет размещать структуры данных на основе узлов, такие как std::set , в разделяемой памяти и отображенные в память файлы, отображаемые по разным адресам в каждый процесс. Причудливые указатели можно использовать независимо от предоставившего их распределителя через шаблон класса std::pointer_traits .(начиная с С++ 11). Функцию std::to_address можно использовать для получения необработанного указателя из причудливого указателя (начиная с C++20).
Использование причудливых указателей и настраиваемого размера/другого типа в стандартной библиотеке поддерживается условно. Реализации могут потребовать, чтобы pointer типа члена , const_pointer , size_type и difference_type были value_type* , const value_type* , std::size_t и std::ptrdiff_t соответственно.
Standard library
Следующие стандартные компоненты библиотеки удовлетворяют требованиям,предъявляемым к Распределителю:
Аллокаторы памяти
Не так давно услышал о том, что существует способ управлять памятью самому, а не использовать, например, new и delete . Может кто-нибудь сможет осветить эту тему поподробнее, и привести какой-нибудь пример кода на С или С++, так как в интернете нашел мало информации на эту тему.
Функциональность оператора new фактически сводится к
- Вызову функции выделения «сырой» памяти требуемого размера
- Инициализации объекта в этой «сырой» памяти
Никто вам не запрещает выполнять эти шаги самостоятельно: выделять «сырую» память любым удобным для вас способом, а затем инициализировать объект в этой памяти при помощи placement-new
При выделении памяти в общем случае следует позаботиться не только об ее размере, но и о соблюдении требований выравнивания.
Удаление объекта повторяет функциональность оператора delete , т.е. делается через синтаксис вызова псевдо-деструктора и вашу же функцию освобождения «сырой» памяти
А если ваша задача выходит за рамки функциональности голого new и delete , и, например, предполагает создание своих аллокаторов для стандартных контейнеров, то это уже несколько другая история.
Прежде всего, почему динамическое управление памятью медленное занятие:
- Выделение памяти при помощи стандартных библиотечных функций обычно требует звонков в ядро, думаю все прекрасно понимают, что эта операция является не совсем быстрой.
- Нет никакого способа узнать, где та память, которую вернёт вам malloc или new, будет находиться по отношению к другим областям памяти в вашем приложении, в связи с этим получается значительная фрагментация памяти, да и к тому же будет больше промахов в кеше.
В связи с этим появляется некая сущность под названием Аллокатор, способная ускорить этот процесс. На самом деле, существуют множество различных способов реализации аллокаторов(линейный, блочный, стековый и т.д.), но в качестве примера ограничимся только линейным, в силу того, что он наиболее простой в реализации и понятный для осознания самой концепции ручного управления памяти. Идея состоит в том, чтобы сохранить указатель на первый адрес памяти вашего блока памяти и перемещать его каждый раз, когда выделение завершено. В этом аллокаторе внутренняя фрагментация сведена к минимуму, потому что все элементы вставляются последовательно (пространственная локальность) и единственная фрагментация между ними — выравнивание. Однако из-за своей простоты данный аллокатор не позволяет освободить определенные позиции памяти, обычно вся память освобождается вместе. Итак приступим, в качестве примера возьмем всем хорошо знакомый язык С
Создаем структуру, которая имеет несколько полей: base_pointer — указатель на выделенный участок памяти при помощи стандартной библиотечной функций, size — размер выделенной памяти, offset — смещение, относительно последнего выделения памяти, уже нашим собственным аллокатором.
Writing custom memory allocators in C++
An essential part of memory management is how memory is allocated and eventually deallocated. By default, memory in C++ programs is allocated using the new keyword and deallocated using the delete keyword. However, sometimes we want more control over how and where objects are allocated/deallocated to avoid issues like fragmentation.
The C++ standard library allows programmers to write custom allocators which can be used by STL containers for dynamic memory allocation, rather than using the standard allocator.
Allocators can be used to improve performance-related issues such as fragmentation, per-thread allocation and NUMA-friendly allocation.
We will see some examples in this post and their benefits, but before we should mention the different properties and requirements an allocator should have in C++.
The allocator API
In C++, an allocator is a template class that allocates and deallocates memory for a specific type T. There are two types of allocators:
- Equal allocators: two equal allocators can be used to allocate and deallocate memory for a type T interchangeably. These are usually stateless allocators.
- Unequal allocators: two unequeal allocators cannot be used to allocate and deallocate memory interchangeably. These are usually stateful allocators.
An allocator class should offer a T* allocate(size_t n) method to allocate n number of objects of type T and a void deallocate(T* p, size_t n) method to deallocate an object of type T .
Additionally, we need to provide an empty copy constructor using a template of type U for full compatibility with STL containers, because the container may also need to allocate internal objects (such as linked list nodes) in addition to objects of type T .
The most simple allocator using malloc() can be implemented as follows:
Because this is a stateless allocator and just uses malloc() , this is an equal allocator, so it is good practice to define the following equality operators for our allocator:
In C++17, you can avoid manually writing the two equality operators above by adding the property using is_always_equal = std::true_type if the allocator is equal (or std::false_type if unequal).
Because SimpleAllocator is an equal allocator, it is legal to do the following:
Example 1: A stateless cache-aligned allocator
As an extension to our first stateless allocator which does nothing interesting, we will now implement an allocator that actually does something useful. In this case, our allocator will automatically eliminate false sharing in an STL container being accessed by multiple threads.
Briefly, the solution to false sharing is to align the shared memory locations such that they end up in different cache lines. On x86 CPUs, L1 cache lines are 64 bytes, so our allocator should allocate objects at 64 byte boundaries. Here is the code:
For allocation, we use C++’s builtin function aligned_alloc() . Everything else is the same, except for the struct rebind . Typically, this struct is generated by the compiler automatically for us but because our allocator takes in more than one template argument (in case the user wants to supply a different alignment amount), we must manually define our own rebind struct (which is used by STL containers to create new allocators without having to call the copy constructor).
You can now specify the custom allocator as a policy template argument to an STL container and verify that all allocations are indeed 64-byte aligned (by inserting a print statement in the allocate method):
Running this benchmark I have prepared to demonstrate the effects of false sharing in a multi-threaded program, for 10 iterations, I obtain:
Measuring latency in this case may not be fully representative because it takes into account thread creation and context switching which adds jitter to the result. However, you can look at the L1 cache misses using perf on Linux and verify that we get less L1 cache misses using the custom allocator.
Example 2: A stateful pool allocator
Now, we look at a classic use case of custom memory allocators: a pool allocator. The goal of a pool-based allocator is to quickly allocate memory for a fixed-type objects while reducing internal fragmentation of memory.
Pool allocators work by allocating large blocks of memory in advance and dividing this block for individual allocations. This means that memory allocation is much faster than calling malloc() , which is slow.
Because a pool allocator has to manage a list of blocks, it is a stateful allocator (and therefore unequal).
Firstly, we need to create a Pool class that manages the memory of chunks of a given size. We use a stack of addresses to quickly pop an available address when we need to allocate an object and push back a newly available address when we deallocate an object at that address.
In our constructor, we specify the fixed-size number of bytes we want to allocate (this will be passed in later as sizeof(T) ) and we reserve memory blocks if specified by the template parameter.
Importantly, we use a std::unique_ptr to automatically free the memory when we are done using the allocator.
The allocate() method will simply return the first address on the stack in O(1) time (unless the stack is empty) and our deallocate() just puts back the address onto the stack.
We also provide a rebind() method to keep STL containers happy.
Now, we need to create the actual PoolAllocator class which manages a Pool instance:
Our allocator class looks very similar as our previous one. The constructor creates a Pool instance which will be used to allocate and deallocate memory. The allocate() and deallocate() methods simply pass the call onto the pool_ instance. Note that our allocator only supports individual allocations: if n > 1, we simply use the standard allocator via malloc() and free() .
We must also provide a struct rebind because our class has more than one template parameter and a rebind copy constructor which just passes the call down to the pool_ instance. We also need to provide default copy/move constructors and assignment operators.
Our benchmark in this case will measure the time it takes to add 1 million integers to a std::list (see the code here). We compare the standard allocator, a pool allocator with no reserved blocks in-advance, one with 100 reserved blocks and another with 1000 reserved blocks:
Even without reserving blocks in advance, we get around x2 speed up! And as expected, if we reserve blocks in advance, we get a x3.85 speed up.
Pool allocators are so useful that they were introduced into the standard library in C++17. You can access them in the <pmr> header (polymorphic memory resource) and use them nicely with STL containers.
Example 3: A huge page allocator
Our final example is just to show off how you can write highly-tailored allocators using a specific feature of Linux: transparent huge pages.
Typically, the OS will allocate memory in fixed-size pages of 4 kB. However, if we have a very large in-memory data structure spanning multiple pages which may be randomly accessed (such as a hash table), we are likely going to suffer from a high number of TLB misses during virtual address translation. The OS can reduce this miss rate by allocating large-sized pages, known as huge pages. These are typically 2 MB, but can be even larger.
A transparent huge page is one that was promoted automatically by the OS from a regular page into a huge page, and is a Linux-specific feature. We can enable THP support for a memory range using the madvise() system call, however Linux does not guarantee that a HP will be allocated. We can use posix_memalign to hint at the kernel that we really want to allocate a huge page. Let’s see the code:
Our benchmark is very simple: we try to add 8 MB worth of integers to a vector and see how long it takes. We obtain:
It is also worth noting that requesting the kernel to promote a regular page into a huge page may cause latency spikes, because the kernel needs to update the page tables accordingly. Also, the kernel may decide to compact unused pages in order to create a huge page on demand, which can also lead to latency spikes. If you try running the benchmark with iterations = 1 , you will see the large variance.
Closing thoughts
We have seen how C++ easily lets us implement custom memory allocators for different applications and use cases. The C++ allocator API can go into much more detail and I am not an expert on memory allocators, this post is just an example highlighting the benefits of memory allocators and a simple demo on how to implement them.
Модель памяти в языках программирования
Память — одна из самых сложных тем в информатике, но понимание устройства памяти компьютера позволяет разрабатывать более эффективные программы, а для более низкоуровневого программирования, например, при разработке ОС, это понимание и вовсе является обязательным.
В этой статье будет рассмотрена модель памяти с высокоуровневой точки зрения — виды памяти, аллокаторы, сборщик мусора.
Виды памяти
Существует 3 типа памяти: статический, автоматический и динамический.
Статический — выделение памяти до начала исполнения программы. Такая память доступна на протяжении всего времени выполнения программы. Во многих языках для размещения объекта в статической памяти достаточно задекларировать его в глобальной области видимости.
Автоматический, также известный как «размещение на стеке», — самый основной, автоматически выделяет аргументы и локальные переменные функции, а также прочую метаинформацию при вызове функции и освобождает память при выходе из неё.
Стек, как структура данных, работает по принципу LIFO («последним пришёл — первым ушёл»). Другими словами, добавлять и удалять значения в стеке можно только с одной и той же стороны.
Автоматическая память работает именно на основе стека, чтобы вызванная из любой части программы функция не затёрла уже используемую автоматическую память, а добавила свои данные в конец стека, увеличивая его размер. При завершении этой функции её данные будут удалены с конца стека, уменьшая его размер. Длина стека останется той же, что и до вызова функции, а у вызывающей функции указатель на конец стека будет указывать на тот же адрес.
Проще всего это понять из примера на С++:
Стек при вызове последней рекурсивной функции будет выглядеть следующим образом:
Детали реализации автоматической памяти могут быть разными в зависимости от конкретной платформы. Например, кому очищать из стека метаинформацию функции и её аргументы: вызывающей функции или вызываемой? Как передавать результат: через стек или, что намного быстрее, через регистры процессора (память, расположенную прямо на кристалле процессора. В этой статье не рассматривается, т. к. в языках программирования высокого уровня зачастую нет прямого доступа к регистрам процессора). На все эти вопросы отвечает конкретная реализация calling convention — описание технических особенностей вызова подпрограмм, определяющее способы передачи параметров/результата функции и способы вызова/возврата из функции.
Таким образом, когда одна функция вызывает другую, последняя всегда в курсе, где ей взять свои аргументы: на конце стека. Но откуда ей знать, где конец стека? В процессоре для этого есть специальный регистр, хранящий указатель на конец стека. В большинстве случаев стек расположен ближе к концу виртуальной памяти и растёт в сторону начала.
Размер автоматической памяти, а он тоже фиксированный, определяется линковщиком (обычно — 1 мегабайт), максимальный размер зависит от конкретной системы и настроек компилятора/линковщика.
Если приложение выйдет за максимум автоматической памяти, его там может ждать Page Fault (сигнал SIGSEGV в POSIX-совместимых системах: Mac OS X, Linux, BSD и т. д.) — ошибка сегментации, приводящая к аварийному завершению программы.
Динамическая — выделение памяти из ОС по требованию приложения.
Автоматическая и статическая память выделяются единоразово перед запуском программы. При их нехватке, либо если модель LIFO не совсем подходит, используется динамическая память.
Приложение при необходимости может запросить у ОС дополнительную память через аллокатор или напрямую через системный вызов. Пример использования динамической памяти с помощью аллокатора ниже на примере языка Си.
После выделения памяти в распоряжение программы поступает указатель на начало выделенной памяти, который, в свою очередь, тоже должен где-то храниться: в статической, автоматической или также в динамической памяти. Для возвращения памяти обратно в аллокатор необходим только сам указатель. Попытка использования уже очищенной памяти может привести к завершению программы с сигналом SIGSEGV.
Языки сверхвысокого уровня используют динамическую память как основную: создают все или почти все объекты в динамической памяти, а на стеке или в статической памяти держат указатели на эти объекты.
Максимальный размер динамической памяти зависит от многих факторов: среди них ОС, процессор, аппаратная архитектура в целом, не говоря уже о самом очевидном — максимальном размере ОЗУ у конкретного устройства. Например x86_64 процессоры используют только 48 бит для адресации виртуальной памяти, что позволяет использовать до 256 ТБ памяти. В следующей статье про более низкоуровневую архитектуру памяти будет объяснено, почему не все 64 бита.
Аллокатор
У динамической памяти есть две явные проблемы. Во-первых, любое выделение/освобождение памяти в ОС — системный вызов, замедляющий работу программы. Решением этой проблемы является аллокатор.
Аллокатор — это часть программы, которая запрашивает память большими кусками напрямую у ОС через системные вызовы (в POSIX-совместимых ОС это mmap для выделения памяти и unmap — для освобождения), затем по частям отдаёт эту память приложению (в Си это могут быть функции malloc() / free() ). Такой подход увеличивает производительность, но может вызвать фрагментацию памяти при длительной работе программы.
malloc() / free() и mmap / unmap — это не одно и то же. Первый является простейшим аллокатором в libc , второй является системным вызовом. В большинстве языков можно использовать только аллокатор по умолчанию, но в языках с более низкоуровневой моделью памяти можно использовать и другие аллокаторы.
Например, boost::pool аллокаторы, созданные для оптимальной работы с контейнерами ( boost::pool_allocator для линейных ( std::vector ), boost::fast_pool_allocator для нелинейных ( std::map, std::list )). Или аллокатор jemalloc, оптимизированный для решения проблем фрагментации и утилизации ресурсов CPU в многопоточных программах. Более подробно о jemalloc можно узнать из доклада с конференции C++ Russia 2018.
Способы контроля динамической памяти
Из-за сложности программ очень трудно определить, когда необходимо освобождать память в ОС, и это вторая явная проблема динамической памяти. Если забыть вызвать munmap() или free() , то произойдет следующая ситуация: приложению память уже не нужна, но ОС всё ещё будет считать, что эта память используется программой. Эту проблему называют «утечкой памяти». Существуют несколько способов автоматического или полуавтоматического решения этой проблемы:
RAII (Получение ресурса есть инициализация) — в ООП — организация получения доступа к ресурсу в конструкторе, а освобождения — в деструкторе соответствующего класса. Достаточно реализовать управление памятью в конструкторах и деструкторах, а компилятор вызовет их автоматически. Например, немного урезанный класс String из статьи про Move-семантику. Выделяем память в конструкторе, очищаем в деструкторе:
Умные указатели на основе RAII — указатели, автоматически владеющие динамической памятью, то есть автоматически освобождающие её, когда она больше не нужна. Умные указатели инкапсулируют только управление памятью объекта, но не сам объект, как, например, происходит в String, который инкапсулирует объект целиком. Примеры умных указателей ниже.
std::unique_ptr — класс уникального указателя, является единственным владельцем памяти и очищает её в своём деструкторе. Поэтому объекты класса std::unique_ptr не могут иметь копий, но могут быть перемещены. Подробнее о семантике перемещения в этой статье.
std::shared_ptr — класс общего указателя, использующий атомарный счётчик ссылок для подсчёта количества владельцев памяти. В конструкторе счётчик инкрементируется, в деструкторе — декрементируется. Как только счётчик становится равным нулю, память освобождается.
Но у std::shared_ptr есть проблема, например, когда объект A ссылается на объект B, а объект B ссылается на объект A. В таком случае у обоих объектов счётчик ссылок никогда не будет меньше 1 и произойдёт утечка памяти. Решений у этой проблемы два. Использование std::weak_ptr , который ссылается на объект, но без счётчика ссылок, и не может быть разыменован без предварительной конвертации в std::shared_ptr . Вторым решением этой проблемы является сборщик мусора.
Сборка мусора — одна из форм автоматического управления динамической памятью, которая помечает все доступные из стека или статической памяти динамически выделенные объекты. Объекты, до которых нельзя добраться через цепочку указателей, начиная с автоматической или статической памяти, т. е. не помеченные сборщиком мусора, очищаются.
Умные указатели и RAII используются в основном в относительно низкоуровневых языках, например, С++ или Swift. В более высокоуровневых языках обычно используется сборщик мусора (Java), хотя может применяться комбинация умного указателя и сборщика мусора (Python).
У каждого способа управления динамической памятью есть свои плюсы и минусы. В основном приходится жертвовать производительностью программы ради скорости и простоты разработки, либо наоборот: высокая производительность, но и высокая требовательность к программистам, из-за чего вероятность ошибиться при разработке программы выше и медленней сам процесс.