Модель памяти C# в теории и на практике. Часть 2
Это вторая часть статьи, в которой обсуждается модель памяти C#. Как пояснялось в первой части (msdn.microsoft.com/magazine/jj863136), компилятор и аппаратное обеспечение могут слегка трансформировать операции программы с памятью такими способами, которые не влияют на однопоточное поведение, но могут затронуть многопоточное. Рассмотрим следующий метод:
void Init()
Если _data и _initialized — обычные (т. е. неизменяемые) поля, компилятору и процессору разрешается такое переупорядочение операций, чтобы Init выполнялся так, будто он написан следующим образом:
void Init()
В предыдущей статье я описал абстрактную модель памяти C#. В этой статье я расскажу, как на самом деле реализуется модель памяти C# на различных аппаратных архитектурах, поддерживаемых Microsoft .NET Framework 4.5.
Оптимизации компилятора
Как упоминалось в первой статье, компилятор может оптимизировать код такими способами, которые приводят к переупорядочению операций с памятью. В .NET Framework 4.5 компилятор csc.exe, транслирующий C# в IL, не выполняет много работы, поэтому он не переупорядочивает такие операции. Однако JIT-компилятор, преобразующий IL в машинный код, действительно выполняет некоторые оптимизации, которые переупорядочивают операции с памятью.
Выделение операции чтения из цикла (loop read hoisting) Рассмотрим шаблон цикла опроса:
class Test < private bool _flag = true; public void Run() < // Задаем _flag как false в другом потоке new Thread(() =>< _flag = false; >).Start(); // Опрашиваем поле _flag, пока оно не станет равным false while (_flag) ; // Цикл может никогда не завершиться! > >
В этом случае JIT-компилятор в .NET 4.5 может переписать цикл так:
if (_flag)
В однопоточной программе это преобразование совершенно корректно и, в целом, выделение операции чтения из цикла — отличная оптимизация. Однако, если _flag задается как false в другом потоке, эта оптимизация может вызвать зависание.
Заметьте, что, если бы поле _flag было изменяемым, JIT-компилятор не стал бы выделять чтение из цикла. (Детальные пояснения по этому шаблону см. в разделе «Цикл опроса» в первой части этой статьи.)
Исключение операции чтения (read elimination) Другая оптимизация компилятора, способная вызывать ошибки в многопоточном коде, иллюстрируется следующим примером:
class Test < private int _A, _B; public void Foo() < int a = _A; int b = _B; . >>
Класс содержит два неизменяемых поля: _A и _B. Метод Foo сначала считывает поле _A, потом поле _B. Однако, поскольку эти поля неизменяемые, компилятор свободен в переупорядочении этих двух операций чтения. Поэтому, если корректность алгоритма зависит от порядка операций чтения, в программе появляется ошибка.
Трудно вообразить, какого выигрыша мог бы добиться компилятор изменением порядка операций чтения. Учитывая то, как написан Foo, компилятор, по-видимому, не стал бы менять порядок операций чтения.
Но переупорядочение все же происходит, если в начало метода Foo я добавляю другое, вполне невинное выражение:
public bool Foo() < if (_B == -1) throw new Exception(); // дополнительное чтение int a = _A; int b = _B; return a >b; >
На первой строке метода Foo компилятор загружает в регистр значение _B. Потом при второй загрузке _B просто использует значение, уже находящееся в регистре, вместо генерации команды настоящей загрузки.
В конечном счете компилятор переписывает метод Foo таким образом:
public bool Foo() < int b = _B; if (b == -1) throw new Exception(); // дополнительное чтение int a = _A; return a >b; >
Хотя этот пример дает весьма приблизительное представление о том, как компилятор оптимизирует код, поучительно посмотреть и на дизассемблированный код:
if (_B == -1) throw new Exception(); push eax mov edx,dword ptr [ecx+8] // Загрузка поля _B в регистр EDX cmp edx,0FFFFFFFFh je 00000016 int a = _A; mov eax,dword ptr [ecx+4] // Загрузка поля _A в регистр EAX return a > b; cmp eax,edx // Сравнение регистров EAX и EDX .
Даже если вы не знаете языка ассемблера, то, что здесь происходит, понять несложно. При оценке условия _B == –1 компилятор загружает поле _B в регистр EDX. Позднее, когда поле _B снова считывается, компилятор просто использует повторно значение, которое уже находится в регистре EDX, вместо выдачи команды на реальное чтение памяти. Соответственно операции чтения _A и _B переупорядочиваются.
В этом случае правильным решением будет пометить поле _A как volatile (изменяемое). Тогда компилятор не должен переупорядочивать операции чтения _A и _B, так как загрузка _A получит семантику загрузки-получения (load-acquire). Однако следует отметить, что .NET Framework до версии 4 включительно не обрабатывает этот случай корректно и, по сути, задание поля _A как volatile не предотвратит переупорядочение операций чтения. Эта проблема была исправлена в .NET Framework 4.5.
Введение дополнительного чтения (read introduction) Как я уже пояснял, компилятор иногда объединяет несколько операций чтения в одну. Кроме того, компилятор может разделить одну операцию чтения на несколько операций. В .NET Framework 4.5 введение дополнительного чтения встречается намного реже, чем объединение, и возможно лишь в очень специфических обстоятельствах. Тем не менее, иногда такое бывает.
Чтобы понять введение дополнительного чтения, рассмотрим следующий пример:
public class ReadIntro < private Object _obj = new Object(); void PrintObj() < Object obj = _obj; if (obj != null) < Console.WriteLine(obj.ToString()); // Возможна генерация NullReferenceException >> void Uninitialize() < _obj = null; >>
При рассмотрении метода PrintObj может показаться, что значение obj никогда не будет null в выражении obj.ToString. Однако эта строка кода на самом деле могла бы привести к генерации исключения NullReferenceException. CLR JIT может скомпилировать метод PrintObj так, будто он написан следующим образом:
void PrintObj() < if (_obj != null) < Console.WriteLine(_obj.ToString()); >>
Поскольку чтение поля _obj было разбито на две операции чтения этого поля, метод ToString теперь может быть вызван для null.
Заметьте, что вам не удастся воспроизвести NullReferenceException, используя этот код в .NET Framework 4.5 на x86 или x64. Введение дополнительного чтения очень трудно воспроизвести в .NET Framework 4.5, но в определенных условиях оно все же происходит.
Реализация модели памяти C# на архитектурах x86 и x64
Так как x86 и x64 имеют одинаковое поведение в отношении модели памяти, я буду рассматривать здесь эти вариации процессорных архитектур совместно.
В отличие от некоторых архитектур процессоры x86-x64 обеспечивают весьма строгие гарантии порядка операций с памятью. По сути, JIT-компилятору не требуется использовать никаких специальных команд на процессорах x86-x64 для достижения семантики volatile; обычные операции с памятью уже поддерживают эту семантику. Но даже при таких гарантиях все равно есть специфические случаи, когда процессор x86-x64 осуществляет переупорядочение операций с памятью.
Переупорядочение операций с памятью на x86-x64 Хотя процессоры x86-x64 предоставляют весьма строгие гарантии порядка операций с памятью, в специфических условиях аппаратное переупорядочение все же происходит.
Процессор x86-x64 не будет переупорядочивать две операции записи, равно как и две операции чтения. Однако один (и только один) возможный эффект переупорядочения заключается в том, что, когда процессор записывает некое значение, оно не становится сразу же доступным другим процессорам. Пример, демонстрирующий такое поведение, приведен на рис. 1.
Рис. 1. StoreBufferExample
class StoreBufferExample < // На x86 и .NET Framework 4.5 не имеет значения, // изменяемые эти поля или нет volatile int A = 0; volatile int B = 0; volatile bool A_Won = false; volatile bool B_Won = false; public void ThreadA() < A = true; if (!B) A_Won = true; >public void ThreadB() < B = true; if (!A) B_Won = true; >>
Рассмотрим случай, где методы ThreadA и ThreadB вызываются из разных потоков применительно к новому экземпляру StoreBufferExample, как показано на рис. 2. Если подумать о результатах работы программы на рис. 2, то вроде бы возможны три случая.
- Поток 1 завершается до запуска потока 2. Результат: A_Won=true, B_Won=false.
- Поток 2 завершается до запуска потока 1. Результат: A_Won=false, B_Won=true.
- Потоки чередуются. Результат: A_Won=false, B_Won=false.
.png)
Рис. 2. Вызов методов ThreadA и ThreadB из разных потоков
| Initial State | Начальное состояние |
| Thread 1 | Поток 1 |
| Thread 2 | Поток 2 |
Но, как это ни удивительно, существует и четвертый случай: есть вероятность того, что оба поля (A_Won и B_Won) будут равны true после выполнения этого кода! Из-за буфера сохранения (store buffer) хранилища могут «запаздывать» и поэтому в конечном счете будут переупорядочены с операцией последующей загрузки. Хотя этот результат не согласуется ни с каким чередованием выполнения потоков 1 и 2, тем не менее, он возможен.
Этот пример интересен потому, что мы имеем процессор (x86-x64) с относительно строгими гарантиями порядка операций с памятью и все поля изменяемые, а переупорядочение операций с памятью все равно наблюдаем. Хотя запись в A является volatile и чтение из A_Won тоже volatile, оба препятствия однонаправленные, и это фактически разрешает такое переупорядочение. Следовательно, метод ThreadA может выполняться так, будто он написан следующим образом:
public void ThreadA()
Одно из возможных исправлений — вставка барьера памяти (memory barrier) в оба метода: ThreadA и ThreadB. Обновленный метод ThreadA мог бы выглядеть так:
public void ThreadA()
CLR JIT вставит вместо барьера памяти команду lock or. В x86 команда с блокировкой (locked instruction) дает побочный эффект сброса буфера сохранения (store buffer):
mov byte ptr [ecx+4],1 lock or dword ptr [esp],0 cmp byte ptr [ecx+5],0 jne 00000013 mov byte ptr [ecx+6],1 ret
Любопытно, что в языке программирования Java применяется другой подход. В модель памяти Java заложено немного более строгое определение volatile, которое не разрешает переупорядочение операций сохранения-загрузки, поэтому компилятор Java на платформе x86, как правило, генерирует команду с блокировкой после volatile-записи.
Заметки по x86-x64 Процессор x86 имеет весьма строгую модель памяти, и единственный источник переупорядочения на аппаратном уровне — буфер сохранения. Этот буфер может привести к тому, что операция записи будет переупорядочена с последующей операцией чтения (переупорядочение «сохранение-загрузка»).
Кроме того, переупорядочение операций с памятью могут вызвать некоторые оптимизации компилятора. А именно, если несколько операций чтения обращаются к одному и тому же участку памяти, компилятор может выбрать выполнение только одной операции чтения и хранение считанного значения в регистре для последующих операций чтения.
Любопытный факт. Семантика volatile в C# очень близко соответствует гарантиям аппаратного переупорядочения процессоров x86-x64. В итоге операции чтения и записи изменяемых полей не требуют специальных команд на платформе x86 — достаточно обычных инструкций чтения и записи (например, MOV). Конечно, ваш код не должен полагаться на такие детали реализации, поскольку они варьируются на разных аппаратных архитектурах и даже в различных версиях .NET.
Реализация модели памяти C# на архитектуре Itanium
Модель памяти в аппаратной архитектуре Itanium менее строгая, чем на процессорах x86-x64. Itanium поддерживался .NET Framework вплоть до версии 4.
Хотя .NET Framework 4.5 больше не поддерживает Itanium, понимание модели памяти Itanium полезно при чтении старых статей по модели памяти .NET и для поддержки кода, который включал рекомендации из этих статей.
Переупорядочение на Itanium У Itanium совсем другой набор команд по сравнению с таковым у x86-x64, и концепции модели памяти проявляются в этом наборе. Itanium различает обычную загрузку (ordinary load, LD) и загрузку-получение (load-acquire, LD.ACQ), а также обычное сохранение (ordinary store, ST) и сохранение-освобождение (store-release, ST.REL).
Обычные операции загрузки и сохранения могут свободно переупорядочиваться на аппаратном уровне, если это не изменяет поведение однопоточного кода. Взгляните, к примеру, на этот код:
class ReorderingExample < int _a = 0, _b = 0; void PrintAB() < int a = _a; int b = _b; Console.WriteLine("A:B:", a, b); > . >
Рассмотрим две операции чтения _a и _b в методе PrintAB. Так как операции чтения обращаются к обычному, неизменяемому полю, компилятор будет использовать LD (не LD.ACQ) для реализации этих операций. Соответственно две операции чтения могут быть переупорядочены аппаратным обеспечением, и PrintAB поведет себя так, словно он написан следующим образом:
void PrintAB() < int b = _b; int a = _a; Console.WriteLine("A:B:", a, b); >
На практике ответ на вопрос о том, произойдет ли переупорядочение, зависит от множества непредсказуемых факторов: что находится в процессорном кеше, насколько занят конвейер процессора и т. д. Однако процессор не будет переупорядочивать две операции чтения, если они связаны через зависимость от данных. Зависимость от данных между двумя операциями чтения наблюдается, когда значение, возвращаемое операцией чтения, определяет адрес данных для последующей операции чтения.
Вот пример, иллюстрирующий зависимость от данных:
class Counter < public int _value; >class Test < private Counter _counter = new Counter(); void Do() < Counter c = _counter; // чтение 1 int value = c._value; // чтение 2 >>
В методе Do процессор Itanium никогда не будет переупорядочивать операции чтения 1 и 2, хотя чтение 1 — обычная загрузка, а не загрузка-получение. Может показаться очевидным, что эти две операции чтения нельзя переупорядочивать: первая из них определяет участок памяти, к которому должна обратиться вторая операция! Однако некоторые процессоры, отличные от Itanium, на самом деле могут переупорядочивать такие операции чтения. Процесс может предположить, какое значение вернет первая операция чтения, и спекулятивно выполнить чтение 2 — даже до выполнения чтения 1. Но опять же Itanium такого никогда не делает.
Я вернусь к обсуждению зависимости от данных на платформе Itanium чуть позже, и ее значимость для модели памяти C# станет понятнее.
Itanium также не будет переупорядочивать две операции обычного чтения, если они связаны через зависимость управления (control dependency). Такая зависимость наблюдается, когда значение, возвращаемое операцией чтения, определяет, будет ли исполняться последующая команда.
Поэтому в данном примере операции чтения _initialized и _data связаны зависимостью управления:
void Print() < if (_initialized) // чтение 1 Console.WriteLine(_data); // чтение 2 else Console.WriteLine("Not initialized"); >
Даже если _initialized и _data являются обычными операциями чтения (неизменяемых полей), процессор Itanium не переупорядочит их. Заметьте, что JIT-компилятор, тем не менее, свободен в переупорядочении этих двух операций и в некоторых случаях так и сделает.
Кроме того, стоит отметить, что подобно x86-x64 процессор Itanium также использует буфер сохранения, поэтому StoreBufferExample, показанный на рис. 1, подвергнется на Itanium такому же переупорядочению, что и на x86-x64. Любопытно, что, если вы используете LD.ACQ для всех операций чтения и ST.REL для всех операций записи на Itanium, то, по сути, вы получите модель памяти x86-x64, где единственная причина переупорядочения — буфер сохранения.
Поведение компилятора на Itanium JIT-компилятор в CLR проявляет одно удивительное поведение на Itanium: все операции записи генерируются как ST.REL, а не ST. Соответственно операции записи в изменяемое и неизменяемое поля, как правило, будут приводить к генерации одной и той же команды для Itanium. Однако операция обычного чтения будет транслироваться как LD, и только операции чтения из изменяемых полей будут генерироваться как LD.ACQ.
Это поведение может стать сюрпризом, так как компилятору определенно не требуется выдавать ST.REL для операций записи в неизменяемые поля. По спецификации ECMA C# компилятор мог бы генерировать обычные инструкции ST. Генерация ST.REL — это нечто дополнительное, что выбирается компилятором для того, чтобы гарантировать ожидаемую работу конкретного (но теоретически неправильного) шаблона.
Трудно вообразить столь важный шаблон, где для операций записи нужно было бы использовать ST.REL, но для операций чтения достаточно LD. В примере с PrintAB, представленном ранее в этом разделе, ограничение только операций записи ничем не поможет, поскольку операции чтения по-прежнему могут переупорядочиваться.
Есть один очень важный сценарий, в котором использовать ST.REL в сочетании с обычными LD вполне достаточно: когда операции загрузки упорядочиваются сами, используя зависимость от данных. Этот шаблон проявляется в отложенной инициализации, а она крайне важна. Пример отложенной инициализации (lazy initialization) показан на рис. 3.
Рис. 3. Отложенная инициализация
// Предупреждение: может не работать на будущих архитектурах // и версиях .NET. Не использовать! class LazyExample < private BoxedInt _boxedInt; int GetInt() < BoxedInt b = _boxedInt; // чтение 1 if (b == null) < lock(this) < if (_boxedInt == null) < b = new BoxedInt(); b._value = 42; // запись 1 _boxedInt = b; // запись 2 >> > int value = b._value; // чтение 2 return value; > >
Чтобы этот код всегда возвращал 42, даже если GetInt вызывается одновременно из нескольких потоков, чтение 1 нельзя переупорядочивать с чтением 2, а запись 1 — с записью 2. Операции чтения не будут переупорядочиваться на процессоре Itanium, так как они связаны через зависимость от данных. А операции записи не будут переупорядочиваться, поскольку CLR JIT транслирует их в команды ST.REL.
Заметьте: если бы поле _boxedInt было изменяемым, код был бы корректным согласно спецификации ECMA C#. Это лучший вариант и, несомненно, единственно правильный. Однако, даже если _boxed не является изменяемым, текущая версия компилятора гарантирует, что этот код все равно будет работать на Itanium.
Конечно, CLR JIT может выполнять на Itanium выделение операции чтения из цикла (loop read hoisting), исключение операции чтения (read elimination) и введение дополнительного чтения (read introduction) точно так же, как на процессорах x86-x64.
Заметки по Itanium Причина, по которой Itanium является интересной частью нашего обсуждения, заключается в том, что это была первая архитектура с нестрогой моделью памяти, которая поддерживалась .NET Framework.
В итоге в ряде статей по модели памяти C# и ключевому слову volatile в этом языке их авторы фактически имели в виду Itanium. В конце концов, Itanium до появления .NET Framework 4.5 был единственной архитектурой, отличной от x86-x64, которая могла выполнять .NET Framework.
Соответственно автор мог сказать нечто вроде: «В модели памяти .NET 2.0 все операции записи являются volatile, даже если выполняются применительно к неизменяемым полям». При этом автор подразумевал, что CLR на Itanium будет транслировать все операции записи в команды ST.REL. Это поведение не гарантируется спецификацией ECMA C#, а значит, может не сохраниться в будущих версиях .NET Framework и на будущих архитектурах (и действительно такое поведение не сохранено в .NET Framework 4.5 на архитектуре ARM).
Аналогично некоторые могли бы доказывать, что отложенная инициализация корректна в .NET Framework, даже если поле хранения (holding field) неизменяемое, тогда как другие заявляли бы, что это поле должно быть изменяемым.
И конечно, разработчики писали код, опираясь на эти (иногда противоречивые) допущения. Вот почему понимание того, что происходит на Itanium, может оказаться полезным, когда вы будете разбираться в параллельном коде, написанном кем-то другим, читать старые статьи или даже просто общаться с другими разработчиками.
Реализация модели памяти C# на ARM
ARM совсем недавно появилась в списке архитектур, поддерживаемых .NET Framework. Подобно Itanium ARM имеет менее строгую модель памяти, чем x86-x64.
Переупорядочение на ARM Как и Itanium, этот процессор разрешает свободно переупорядочивать обычные операции чтения и записи. Однако решение, заложенное в ARM для управления перемещением операций чтения и записи, несколько отличается от такового на Itanium. ARM предоставляет одну инструкцию — DMB, которая действует как полноценный барьер памяти. Никакие операции с памятью не могут преодолеть DMB в любом направлении.
В дополнение к ограничениям, накладываемым DMB, ARM также поддерживает зависимость от данных, но игнорирует зависимость управления. Пояснения по зависимостям см. в разделе «Переупорядочение на Itanium» ранее в этой статье.
Поведение компилятора на ARM Команда DMB используется для реализации семантики volatile в C#. JIT-компилятор CLR на ARM реализует операцию чтения из изменяемого поля как обычную команду чтения (например, LDR), за которой следует команда DMB. Так как инструкция DMB предотвратит переупорядочение volatile-чтения с любой последующей операцией, это решение корректно реализует семантику получения (acquire semantics).
Запись в изменяемое поле реализуется инструкцией DMB, за которой следует обычная операция записи (например, STR). Так как DMB предотвращает переупорядочение volatile-записи с любыми предыдущими операциями, это решение корректно реализует семантику освобождения (release semantics).
Как и в случае процессора Itanium, было бы неплохо выйти за рамки спецификации ECMA C# и сохранить работоспособность шаблона отложенной инициализации, поскольку на нее полагается огромное количество кода. Однако фактическое превращение любой записи в volatile-операцию — не слишком хорошее решение на ARM, потому что выполнение команды DBM обходится весьма дорого.
В .NET Framework 4.5 JIT-компилятор CLR использует несколько иной фокус, чтобы добиться работы отложенной инициализации. Все перечисленное ниже интерпретируется как барьеры «освобождения».
- Операции записи в поля ссылочных типов в куче, управляемой сборщиком мусора (garbage collector, GC).
- Операции записи в статические поля ссылочных типов.
В результате любая операция записи может публиковать объект, обрабатываемый как барьер освобождения.
Ниже дана соответствующая часть LazyExample (помните, что ни одно из полей не является изменяемым):
b = new BoxedInt(); b._value = 42; // запись 1 // DMB будет генерироваться здесь _boxedInt = b; // запись 2
Поскольку CLR JIT генерирует команды DMB до публикации объекта в поле _boxedInt, операции записи 1 и 2 не будут переупорядочиваться. А так как ARM соблюдает зависимость от данных, операции чтения в шаблоне отложенной инициализации тоже не будут переупорядочиваться, и код будет работать корректно на ARM.
Таким образом, CLR JIT предпринимает дополнительные усилия (выходящие за рамки того, что определено в спецификации ECMA C#) для сохранения работоспособности наиболее распространенной вариации некорректной отложенной инициализации на ARM.
В качестве последнего комментария по ARM замечу, что, как и на x86-x64 и Itanium, выделение операции чтения из цикла, исключение операций чтения и введение дополнительной операции чтения являются допустимыми оптимизациями, если речь идет о JIT-компиляторе в CLR.
Пример: отложенная инициализация
Может оказаться весьма поучительным посмотреть на несколько вариаций шаблона отложенной инициализации и подумать о том, как они будут вести себя на разных архитектурах.
Корректная реализация Реализация отложенной инициализации на рис. 4 корректна согласно модели памяти C#, определенной в спецификации ECMA C#, и поэтому она гарантирует работу на всех архитектурах, поддерживаемых текущей и будущими версиями .NET Framework.
Рис. 4. Корректная реализация отложенной инициализации
class BoxedInt < public int _value; public BoxedInt() < >public BoxedInt(int value) < _value = value; >> class LazyExample < private volatile BoxedInt _boxedInt; int GetInt() < BoxedInt b = _boxedInt; if (b == null) < b = new BoxedInt(42); _boxedInt = b; >return b._value; > >
Хотя пример кода корректен, на практике все равно предпочтительнее использовать тип Lazy или LazyInitializer.
Некорректная реализация номер 1 На рис. 5 показана реализация, некорректная согласно модели памяти C#. Несмотря на это, данная реализация, вероятно, будет работать на x86-x64, Itanium и ARM в .NET Framework. Эта версия кода неправильна. Поскольку _boxedInt не является изменяемым, компилятору C# разрешается переупорядочивать чтение 1 с чтением 2 или запись 1 с записью 2. Любое из этих переупорядочений потенциально может привести к тому, что GetInt вернет 0.
Рис. 5. Некорректная реализация отложенной инициализации
// Предупреждение: плохой код class LazyExample < private BoxedInt _boxedInt; // это поле неизменяемое int GetInt() < BoxedInt b = _boxedInt; // чтение 1 if (b == null) < b = new BoxedInt(42); // запись 1 (внутри конструктора) _boxedInt = b; // запись 2 >return b._value; // чтение 2 > >
Однако этот код будет вести себя корректно (т. е. всегда возвращать 42) на всех архитектурах в .NET Framework версий 4 и 4.5.
- x86-x64:
- операции записи и чтения не будут переупорядочиваться. В этом коде нет шаблона сохранения-загрузки (store-load), и поэтому нет причин, которые заставили бы компилятор кешировать значения в регистрах.
- операции записи не будут переупорядочиваться, так как все они выражаются командами ST.REL;
- операции чтения не будут переупорядочиваться из-за зависимости от данных.
- операции записи не будут переупорядочиваться, так как перед «_boxedInt = b» генерируется инструкция DMB;
- операции чтения не будут переупорядочиваться из-за зависимости от данных.
Конечно, вы должны использовать эту информацию только для того, чтобы понять поведение существующего кода. Не применяйте этот шаблон при написании нового кода.
Некорректная реализация номер 2 Реализация на рис. 6 может не работать на процессорах ARM и Itanium.
Рис. 6. Вторая некорректная реализация отложенной инициализации
// Предупреждение: плохой код class LazyExample < private int _value; private bool _initialized; int GetInt() < if (!_initialized) // чтение 1 < _value = 42; _initialized = true; >return _value; // чтение 2 > >В этой версии отложенной инициализации используются два поля: _value (для отслеживания данных) и _initialized (для определения того, было ли инициализировано поле). В итоге операции чтения 1 и 2 больше не связаны через зависимость от данных. Кроме того, на ARM операции записи тоже могут быть переупорядочены по тем же причинам, что и в следующей некорректной реализации (номер 3).
На практике эта версия может работать неправильно и возвращать 0 на ARM и Itanium. Конечно, GetInt разрешается возвращать 0 на x86-x64 (и это тоже результат JIT-оптимизаций), но такого поведения в .NET Framework 4.5 не наблюдается.
Некорректная реализация номер 3 Наконец, пример можно сделать неработоспособным даже на x86-x64. Достаточно добавить одну невинно выглядящую операцию чтения, как показано на рис. 7.
Рис. 7. Третья некорректная реализация отложенной инициализации
// Предупреждение: плохой код class LazyExample < private int _value; private bool _initialized; int GetInt() < if (_value < 0) throw new Exception(); // Примечание: дополнительные операции чтения // для предварительной загрузки _value в регистр if (!_initialized) // чтение 1 < _value = 42; _initialized = true; return _value; >return _value; // чтение 2 > >Заключение
При написании нового многопоточного кода, в целом, следует вообще избегать сложности модели памяти C#, используя высокоуровневые примитивы параллельной обработки вроде блокировок, параллельных наборов, задач и параллельных циклов. Если вы пишете код, интенсивно использующий процессор, иногда имеет смысл применять изменяемые поля при условии, что вы полагаетесь только на гарантии спецификации ECMA C#, а не на детали реализации, специфичные для конкретной архитектуры.
Игорь Островский (Igor Ostrovsky) — старший инженер-разработчик ПО в Microsoft. Работал над Parallel LINQ, Task Parallel Library и другими библиотеками и примитивами для распараллеливания кода в Microsoft .NET Framework. Ведет блог по вопросам программирования на igoro.com.
Выражаю благодарность за рецензирование статьи экспертам Джо Даффи (Joe Duffy), Эрику Ейлебрехту (Eric Eilebrecht), Джо Хоугу (Joe Hoag), Эмаду Омара (Emad Omara), Гранту Рихинсу (Grant Richins), Ярославу Шевчуку (Jaroslav Sevcik) и Стефену Таубу (Stephen Toub).
1.2. 5.Модель управления памятью
Виртуальная память — это способ выполнения программ, размер которых превышает размер доступной физической памяти. Части программ (оверлеи) хранятся во вторичной памяти и пересылаются в ОП по мере надобности. Пересылка осуществляется автоматически операционной системой и невидима для программы.
Виртуальная память основана на разделении понятий адресного пространства и адресов памяти (физические адреса). Виртуальное адресное пространство — это множество адресов, к которым может обращаться программа данного ЦПУ, а реальные адреса ОП МПС – физическим адресным пространством. Виртуальное адресное пространство ЦПУ намного больше его адресного пространства, определяемого разрядностью его адресного регистра.
1.2.5.1. Страничная организация памяти
Пусть адресное пространство ЦПУ равно 65536 байт, а физическое адресное пространство МПС содержит ячейки с 0 по 4095. Можно было бы настроить ЦПУ МПС так, чтобы при обращении к адресу 4096 должно использоваться слово из памяти 0, а при обращении к адресу 4097 – слово из памяти с адресом 1, при обращении к адресу 8191 – слово из памяти с адресом 4095. Другими словами, определено отображение из адресного пространства в действительные адреса памяти – механизм трансляции адресов. Что произойдет, если ЦПУ обратится к адресу 8192. В ЦПУ без виртуальной памяти произойдет исключение по несуществующему адресу физической памяти. В МП с виртуальной памятью будет иметь место следующая последовательность шагов:
1. Байты из ОП будут отправлены во вторичную память.
2. Байты с 8192 по 12287 будут загружены из вторичной памяти в ОП.
3. Отображение адресов изменится: теперь адреса с 8192 по 12287 соответствуют ячейкам физической памяти с 0 по 4095.
4. Выполнение программы продолжится, как ни в чем не бывало.
Такая технология автоматического отображения называется страничной организацией памяти, а части программы, которые считываются из вторичной памяти, страницами. Страница – набор последовательных байт фиксированной длины, не имеющих непосредственной связи с логической структурой программы.
Виртуальное адресное пространство разбивается на ряд страниц равного размера, обычно от 512 до 64 Кбайт, хотя иногда встречается 4 Мбайт. Размер страницы всегда должен быть степенью двойки. Физическое адресное пространство тоже разбивается на части равного размера таким образом, чтобы каждая такая часть ОП вмещала ровно одну страницу. Эти части ОП называют страничными кадрами.
Рассмотрим для примера, как можно 32-разрядный логический адрес (пусть виртуальная память тоже 32 разряда, а размер страницы 4 Кбайт) отобразить на физический адрес ОП объемом 32 Кбайт. В ЦПУ это отображение выполняет MMU. Преобразование 32-битного логического адреса в 15-битный адрес ОП (8 страничных кадров) выполняется следующим образом. Узел MMU разделяет логический адрес на 20-битный номер виртуальной страницы и 12-битовое смещение внутри страницы. Номер виртуальной страницы используется в качестве индекса в таблице страниц для нахождения нужной страницы. На рис.5 номер виртуальной страницы равен 5, поэтому из таблицы выбирается элемент 5 с номером страничного кадра 6 .
Сначала MMU проверяет, находится ли нужная страница в текущий момент в ОП, читая бит присутствия в данном элементе таблицы страниц. В примере этот бит равен 1, т.е. страница в памяти.
Рис.5. Формирования адреса ОП
При обращении к адресу страницы, которой нет в памяти, происходит исключение из-за отсутствия страницы. В случае такой ошибки операционная система должна считать нужную страницу из вторичной памяти, ввести новый адрес физической памяти в таблицу страниц, а затем повторить команду, которая вызвала исключение. Такой метод работы с виртуальной памятью называется вызовом страницы по требованию. Для каждой программы распределение памяти уникально и при переключении с одной программы на другую меняется, поэтому в системах с разделением времени такой подход не годится.
Другой подход основан на наблюдении, что большинство команд обращаются к адресному пространству не равномерно. Обычно большинство обращений относится к небольшому числу страниц. При обращении к памяти можно вызвать команду, вызвать данные или сохранить данные. В каждый момент существует набор страниц, которые использовались при последних m обращениях. Этот набор называют рабочим множеством.
Поскольку рабочее множество меняется очень медленно, можно опираясь на последнее перед остановкой программы рабочее множество, предсказать, какие страницы понадобятся при новом запуске программы. Эти страницы можно загрузить заранее перед очередным запуском программы.
При обращении программы к странице, которая отсутствует в памяти ее нужно вызвать из вторичной памяти. Однако, чтобы освободить для не место, нужно во вторичную память отправить какую-нибудь страницу. По одному из алгоритмов удаляется та страница, которая использовалась наиболее давно, поскольку вероятность того, что она будет в текущем рабочем множестве, очень мала. Этот алгоритм называется LRU (Last Recently Used – наиболее давно использовавшиеся элементы). Иногда LRU приводит к патологическим ситуациям (например программа, цикл которой простирается на несколько страниц).
Другой алгоритм – FIFO (First-in First-out – первым поступил, первым выводится) удаляет ту страницу, которая раньше всех загружалась, независимо от того, когда в последний раз производилось обращение к этой странице. С каждым страничным кадром связан отдельный счетчик. Изначально все счетчики установлены на 0.
После каждой ошибки из-за отсутствия страниц счетчик каждой страницы, находящейся в памяти, увеличивается на 1, а счетчик только что вызванной страницы принимает значение 0. Когда нужно выбрать страницу для удаления, выбирается страница с самым большим значением счетчика.
Если размер рабочего множества больше, чем число допустимых страничных кадров, ни один алгоритм не дает хороших результатов, и ошибки из-за отсутствия страниц будут происходить часто. Если программа постоянно вызывает подобные ошибки, то говорят, что наблюдается пробуксовка (thrashing).
Если страница, которую нужно удалить не менялась, с тех пор как ее считали, то необязательно ее записывать обратно во вторичную память, поскольку точная копия уже существует. В MMU содержится бит для каждой страницы, который равен 0 при загрузке страницы и принимает значение 1, когда изменяются данные в этой странице. По этому биту ОС определяет необходимость перезаписи страницы.
Организация и модели памяти, адресация
Память – способность объекта обеспечивать хранение данных.
Все объекты, над которыми выполняются команды, как и сами команды, хранятся в памяти компьютера.
Память состоит из ячеек, в каждой из которых содержится 1 бит информации, принимающий одно из двух значений: 0 или 1. Биты обрабатывают группами фиксированного размера. Для этого группы бит могут записываться и считываться за одну базовую операцию. Группа из 8 бит называется .
Байты последовательно располагаются в памяти компьютера.
- 1 килобайт (Кбайт) = 2 10 = 1 024 байт
- 1 мегабайт (Мбайт) = 2 10 Кбайт = 2 20 байт = 1 048 576 байт
- 1 гигабайт (Гбайт) = 2 10 Мбайт = 2 30 байт = 1 073 741 824 байт
Для доступа к памяти с целью записи или чтения отдельных элементов информации используются идентификаторы , определяющие их расположение в памяти. Каждому идентификатору в соответствие ставится адрес . В качестве адресов используются числа из диапазона от 0 до 2 k -1 со значением k, достаточным для адресации всей памяти компьютера.Все 2 k адресов составляют адресное пространство компьютера .
Способы адресации байтов

Существует прямой и обратный способы адресации байтов.
При обратном способе адресации байты адресуются слева направо, так что самый старший (левый) байт слова имеет наименьший адрес.
Прямым способом называется противоположная система адресации. Компиляторы высокоуровневых языков поддерживают прямой способ адресации.
Объект занимает целое слово. Поэтому для того, чтобы обратиться к нему в памяти, нужно указать адрес, по которому этот объект хранится.
Организация памяти
Физическая память, к которой микропроцессор имеет доступ по шине адреса, называется оперативной памятью ОП (или оперативным запоминающим устройством — ОЗУ).
Механизм управления памятью полностью аппаратный, т.е. программа сама не может сформировать физический адрес памяти на адресной шине.
Микропроцессор аппаратно поддерживает несколько моделей использования оперативной памяти:- сегментированную модель
- страничную модель
- плоскую модель
В сегментированной модели память для программы делится на непрерывные области памяти, называемые сегментами . Программа может обращаться только к данным, которые находятся в этих сегментах.
Сегмент представляет собой независимый, поддерживаемый на аппаратном уровне блок памяти.Сегментация — механизм адресации, обеспечивающий существование нескольких независимых адресных пространств как в пределах одной задачи, так и в системе в целом для защиты задач от взаимного влияния.
Каждая программа в общем случае может состоять из любого количества сегментов, но непосредственный доступ она имеет только к 3 основным сегментам и к 3 дополнительным сегментам, обслуживаемых 6 сегментными регистрами. К основным сегментам относятся:
- Сегмент кодов ( .CODE ) – содержит машинные команды для выполнения. Обычно первая выполняемая команда находится в начале этого сегмента, и операционная система передает управление по адресу данного сегмента для выполнения программы. Регистр сегмента кодов ( CS ) адресует данный сегмент.
- Сегмент данных ( .DATA ) – содержит определенные данные, константы и рабочие области, необходимые программе. Регистр сегмента данных ( DS ) адресует данный сегмент.
- Сегмент стека ( .STACK ). Стек содержит адреса возврата как для программы (для возврата в операционную систему), так и для вызовов подпрограмм (для возврата в главную программу). Регистр сегмента стека ( SS ) адресует данный сегмент. Адрес текущей вершины стека задается регистрами SS:ESP .
Регистры дополнительных сегментов ( ES, FS, GS ), предназначены для специального использования.
Для доступа к данным внутри сегмента обращение производится относительно начала сегмента линейно, т.е. начиная с 0 и заканчивая адресом, равным размеру сегмента. Для обращения к любому адресу в программе, компьютер складывает адрес в регистре сегмента и смещение — расположение требуемого адреса относительно начала сегмента. Например, первый байт в сегменте кодов имеет смещение 0, второй байт – 1 и так далее.
Таким образом, для обращения к конкретному физическому адресу ОЗУ необходимо определить адрес начала сегмента и смещение внутри сегмента.
Физический адрес принято записывать парой этих значений, разделенных двоеточиемсегмент : смещение
Страничная модель памяти – это надстройка над сегментной моделью. ОЗУ делится на блоки фиксированного размера, кратные степени 2, например 4 Кб. Каждый такой блок называется страницей . Основное достоинство страничного способа распределения памяти — минимально возможная фрагментация. Однако такая организация памяти не использует память достаточно эффективно за счет фиксированного размера страниц.
Плоская модель памяти предполагает, что задача состоит из одного сегмента, который, в свою очередь, разбит на страницы.
Достоинства:- при использовании плоской модели памяти упрощается создание и операционной системы, и систем программирования;
- уменьшаются расходы памяти на поддержку системных информационных структур.
В абсолютном большинстве современных 32(64)-разрядных операционных систем (для микропроцессоров Intel) используется плоская модель памяти.
Модели памяти
Директива .MODEL определяет модель памяти, используемую программой. После этой директивы в программе находятся директивы объявления сегментов ( .DATA, .STACK, .CODE, SEGMENT ). Синтаксис задания модели памяти
.MODEL модификатор МодельПамяти СоглашениеОВызовах
Параметр МодельПамяти является обязательным.
Основные модели памяти:
Модель памяти Адресация кода Адресация данных Операци-
онная системаЧередование кода и данных TINY NEAR NEAR MS-DOS Допустимо SMALL NEAR NEAR MS-DOS, Windows Нет MEDIUM FAR NEAR MS-DOS, Windows Нет COMPACT NEAR FAR MS-DOS, Windows Нет LARGE FAR FAR MS-DOS, Windows Нет HUGE FAR FAR MS-DOS, Windows Нет FLAT NEAR NEAR Windows NT, Windows 2000, Windows XP, Windows Vista Допустимо Модель tiny работает только в 16-разрядных приложениях MS-DOS. В этой модели все данные и код располагаются в одном физическом сегменте. Размер программного файла в этом случае не превышает 64 Кбайт.
Модель small поддерживает один сегмент кода и один сегмент данных. Данные и код при использовании этой модели адресуются как near (ближние).
Модель medium поддерживает несколько сегментов программного кода и один сегмент данных, при этом все ссылки в сегментах программного кода по умолчанию считаются дальними (far), а ссылки в сегменте данных — ближними (near).
Модель compact поддерживает несколько сегментов данных, в которых используется дальняя адресация данных (far), и один сегмент кода с ближней адресацией (near).
Модель large поддерживает несколько сегментов кода и несколько сегментов данных. По умолчанию все ссылки на код и данные считаются дальними (far).
Модель huge практически эквивалентна модели памяти large.Особого внимания заслуживает модель памяти flat , которая используется только в 32-разрядных операционных системах. В ней данные и код размещены в одном 32-разрядном сегменте. Для использования в программе модели flat перед директивой .model flat следует разместить одну из директив:
Желательно указывать тот тип процессора, который используется в машине, хотя это не является обязательным требованием. Операционная система автоматически инициализирует сегментные регистры при загрузке программы, поэтому модифицировать их нужно только в случае если требуется смешивать в одной программе 16-разрядный и 32-разрядный код. Адресация данных и кода является ближней ( near ), при этом все адреса и указатели являются 32-разрядными.
Параметр модификатор используется для определения типов сегментов и может принимать значения use16 (сегменты выбранной модели используются как 16-битные) или use32 (сегменты выбранной модели используются как 32-битные).
Параметр СоглашениеОВызовах используется для определения способа передачи параметров при вызове процедуры из других языков, в том числе и языков высокого уровня (C++, Pascal). Параметр может принимать следующие значения:
При разработке модулей на ассемблере, которые будут применяться в программах, написанных на языках высокого уровня, обращайте внимание на то, какие соглашения о вызовах поддерживает тот или иной язык. Используются при анализе интерфейса программ на ассемблере с программами на языках высокого уровня.
Модель памяти в языках программирования
Понимание модели памяти поможет эффективней разрабатывать приложения. В статье рассмотрена модель памяти в языках программирования с высокоуровневой точки зрения.
Память — одна из самых сложных тем в информатике, но понимание устройства памяти компьютера позволяет разрабатывать более эффективные программы, а для более низкоуровневого программирования, например, при разработке ОС, это понимание и вовсе является обязательным.
В этой статье будет рассмотрена модель памяти с высокоуровневой точки зрения — виды памяти, аллокаторы, сборщик мусора.
Виды памяти
Существует 3 типа памяти: статический, автоматический и динамический.
Статический — выделение памяти до начала исполнения программы. Такая память доступна на протяжении всего времени выполнения программы. Во многих языках для размещения объекта в статической памяти достаточно задекларировать его в глобальной области видимости.
int // определение статической глобальной переменной int main() < std::coutАвтоматический, также известный как «размещение на стеке», — самый основной, автоматически выделяет аргументы и локальные переменные функции, а также прочую метаинформацию при вызове функции и освобождает память при выходе из неё.
Стек, как структура данных, работает по принципу LIFO («последним пришёл — первым ушёл»). Другими словами, добавлять и удалять значения в стеке можно только с одной и той же стороны.
Автоматическая память работает именно на основе стека, чтобы вызванная из любой части программы функция не затёрла уже используемую автоматическую память, а добавила свои данные в конец стека, увеличивая его размер. При завершении этой функции её данные будут удалены с конца стека, уменьшая его размер. Длина стека останется той же, что и до вызова функции, а у вызывающей функции указатель на конец стека будет указывать на тот же адрес.
Проще всего это понять из примера на С++:
int main() < int a = 3; int result = factorial(a); std::cout int factorial(int n)
Стек при вызове последней рекурсивной функции будет выглядеть следующим образом:
Детали реализации автоматической памяти могут быть разными в зависимости от конкретной платформы. Например, кому очищать из стека метаинформацию функции и её аргументы: вызывающей функции или вызываемой? Как передавать результат: через стек или, что намного быстрее, через регистры процессора (память, расположенную прямо на кристалле процессора. В этой статье не рассматривается, т. к. в языках программирования высокого уровня зачастую нет прямого доступа к регистрам процессора). На все эти вопросы отвечает конкретная реализация calling convention — описание технических особенностей вызова подпрограмм, определяющее способы передачи параметров/результата функции и способы вызова/возврата из функции.
Таким образом, когда одна функция вызывает другую, последняя всегда в курсе, где ей взять свои аргументы: на конце стека. Но откуда ей знать, где конец стека? В процессоре для этого есть специальный регистр, хранящий указатель на конец стека. В большинстве случаев стек расположен ближе к концу виртуальной памяти и растёт в сторону начала.
Размер автоматической памяти, а он тоже фиксированный, определяется линковщиком (обычно — 1 мегабайт), максимальный размер зависит от конкретной системы и настроек компилятора/линковщика.
Если приложение выйдет за максимум автоматической памяти, его там может ждать Page Fault (сигнал SIGSEGV в POSIX-совместимых системах: Mac OS X, Linux, BSD и т. д.) — ошибка сегментации, приводящая к аварийному завершению программы.
Динамическая — выделение памяти из ОС по требованию приложения.
Автоматическая и статическая память выделяются единоразово перед запуском программы. При их нехватке, либо если модель LIFO не совсем подходит, используется динамическая память.
Приложение при необходимости может запросить у ОС дополнительную память через аллокатор или напрямую через системный вызов. Пример использования динамической памяти с помощью аллокатора ниже на примере языка Си.
char *i = malloc(sizeof(char)); // просим у аллокатора память для char if (i != NULL) // аллокатор может вернуть NULL (0) < *i = 120; // делаем что-то с памятью, на которую указывает указатель i printf("Чтение символа из выделенной памяти: %c\n", *i); free(i); // возвращаем память обратно аллокатору >После выделения памяти в распоряжение программы поступает указатель на начало выделенной памяти, который, в свою очередь, тоже должен где-то храниться: в статической, автоматической или также в динамической памяти. Для возвращения памяти обратно в аллокатор необходим только сам указатель. Попытка использования уже очищенной памяти может привести к завершению программы с сигналом 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-семантику. Выделяем память в конструкторе, очищаем в деструкторе:
class String < public: explicit String(const char *const c_string) < size = strlen(c_string) + 1; this->c_string = new char[size]; // выделяем память strcpy(this->c_string, c_string); > ~String() noexcept < delete[] c_string; // очищаем память >private: char *c_string; size_t size; >;Умные указатели на основе 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).
У каждого способа управления динамической памятью есть свои плюсы и минусы. В основном приходится жертвовать производительностью программы ради скорости и простоты разработки, либо наоборот: высокая производительность, но и высокая требовательность к программистам, из-за чего вероятность ошибиться при разработке программы выше и медленней сам процесс.