Добавить в корзинуПозвонить
Найти в Дзене
ZDG

Rust и другие языки: Что там с памятью?

В написании игры на Rust приходится делать краткий довольно обширный детур, чтобы разобраться с тем, как этот язык работает с памятью. Предыдущие части: Колхозим интерфейсы, Где у него классы, Поддержка SDL2, Полируем ржавчину Память компьютера – это одно непрерывное и однородное пространство адресов. Память же программы делится на несколько логических областей: статическая, стек и куча. Это память, которую занимает сам код программы. Вместе с кодом могут храниться данные. Например, когда в программе мы пишем строку "Hello world", память для неё выделяется статически, то есть заранее и навсегда. Если мы скомпилируем программу и затем откроем .exe-файл текстовым редактором, то среди бинарного кода увидим ту самую строку "Hello world". При загрузке программы в память все статические данные грузятся вместе с ней и существуют всю жизнь программы. Эту память нельзя освободить, но теоретически можно повторно использовать. Например, когда мы напечатали на экране строку "Hello world", она боль
Оглавление

В написании игры на Rust приходится делать краткий довольно обширный детур, чтобы разобраться с тем, как этот язык работает с памятью.

Предыдущие части: Колхозим интерфейсы, Где у него классы, Поддержка SDL2, Полируем ржавчину

Память компьютера – это одно непрерывное и однородное пространство адресов. Память же программы делится на несколько логических областей: статическая, стек и куча.

1. Статическая

Это память, которую занимает сам код программы. Вместе с кодом могут храниться данные. Например, когда в программе мы пишем строку "Hello world", память для неё выделяется статически, то есть заранее и навсегда. Если мы скомпилируем программу и затем откроем .exe-файл текстовым редактором, то среди бинарного кода увидим ту самую строку "Hello world".

При загрузке программы в память все статические данные грузятся вместе с ней и существуют всю жизнь программы. Эту память нельзя освободить, но теоретически можно повторно использовать. Например, когда мы напечатали на экране строку "Hello world", она больше не нужна, и мы могли бы записать в эту память другие данные. Но такие методы нетривиальны и зачастую прямо запрещены.

2. Стек

В отличие от статической памяти, стек предназначен исключительно для динамически создаваемых переменных. Это выделенная область памяти, в которой мы можем заимствовать и возвращать кусочки. Делается это просто – указатель стека показывает на начало свободной области. Заняв из этой области какое-то количество байт, мы сдвигаем указатель стека дальше. Освобождая память, мы возвращаем указатель стека на такое же количество байт назад.

Выделение на стеке работает быстро и просто. Но освобождение памяти должно происходить в порядке, обратном выделению. Последний занятый кусок освобождается первым, и т.д.

3. Куча

Ещё одна область памяти, где также можно занимать и возвращать кусочки. Но в отличие от стека, она не иерархична. Куски можно запрашивать и возвращать в любом порядке. В стеке свободная область всегда непрерывна, а в куче со временем образуется фрагментация, которая затрудняет выделение. Поэтому куче требуется специальный менеджер и таблица занятых участков, что делает выделение в ней медленнее, чем в стеке.

Время жизни переменных

Программа делится на функциональные блоки. Это условия, циклы, функции или просто места, обозначенные как {}:

{ a = 5; }

Переменная, объявленная внутри блока, получает память из стека. Когда блок заканчивается, память освобождается и переменная "исчезает".

Так как блоки могут быть вложены друг в друга, внутренние блоки всегда закрываются раньше, чем внешние. Это соответствует порядку выделения и освобождения в стеке. И пока мы находимся во внутреннем блоке, все переменные во внешних блоках ещё живы, то есть мы можем к ним законно обращаться.

А вот обратиться из внешнего блока к переменной, объявленной во внутреннем, уже нельзя. Её просто не существует на этом уровне.

Все языки следуют этим правилам, но у некоторых синтаксис вводит в заблуждение. Скажем, в Javascript такой код:

-2

выведет все три переменные. Хотя i и b по идее существуют только внутри блока for. Это не ошибка, а особенность языка (и не только JavaScript). Несмотря на то, что объявления переменных (var) делаются внутри блока for, они неявно переносятся в верхний уровень.

Поэтому вышеуказанный код эквивалентен такому:

-3

Позже в JavaScript появился ещё один способ объявления переменных – let.

-4

В таком виде переменные живут только внутри блока и попытка обратиться к ним снаружи вызывает ошибку.

Теперь посмотрим на такое присваивание на JavaScript:

-5

Хотя переменная b перестала существовать после блока if, её значение (10) успешно перешло в переменную a. Число 10 – примитивный тип, который хранится прямо на стеке. В переменную a оно было просто скопировано. Затем оно было удалено из стека, но в переменной a осталась копия.

Усложним ситуацию:

-6

Теперь вместо примитивного типа мы присваиваем переменной b целый объект. Объект это структура в памяти, которая может занимать много байт. Теоретически ничего не мешает разместить объект на стеке, зарезервировав для него нужное количество байт. Тогда после выхода из блока эта память должна быть освобождена.

Мы сделали внутри блока присвоение a = b, то есть теперь a это тот же самый объект. Но если его память будет освобождена после выхода из блока, то чему станет равно значение переменной a?

В данном примере с a всё в порядке и она по-прежнему содержит объект. Этого можно добиться двумя способами:

1) Как и в случае с примитивным типом, в переменную a копируется весь объект. Мы получаем копию, и оригинал можно удалить.

Но во-первых, это затратно. Если перемещение значений примитивных типов делается за одну операцию, то копировать объекты нужно в цикле, побайтово.

Во-вторых, переменной a под копию объекта тоже нужно выделить память. Ведь первоначально было a = 5, то есть было выделено место под примитивный тип, а какого размера объект будет присвоен в дальнейшем, транслятор языка не знает. Значит, нужно будет как-то динамически выделять память, а где? Точно не на стеке, так как это нарушит вложенность блоков.

Тогда в куче? Верно. Но тогда почему оригинальный объект сразу не сделать в куче? Что приводит нас ко второму, истинному способу:

2) Объект { id:1 } создаётся в куче. Переменной b присваивается адрес этого объекта. Иначе говоря, указатель. У него примитивный тип (по сути это целое число, соответствующее адресу в памяти). И в стек кладётся не сам объект, а указатель.

Указатель мы можем спокойно присвоить переменной a, просто скопировав его. Далее: блок заканчивается, переменная b исчезает, указатель удаляется из стека. Но его копия остаётся в переменной a, а объект остаётся в куче. Указатель по-прежнему указывает на объект, поэтому всё работает.

Проблема освобождения

Когда выделенная память теряет актуальность, её надо освобождать. Это не обязательное требование, но очевидно, что память не бесконечная.

На стеке освобождение происходит само собой. В куче же возникает проблема: как определить, нужен ещё выделенный кусок или нет?

Язык C полностью возлагает эту ответственность на программиста. Когда программист решает, что память из кучи больше не нужна, он явно освобождает её. Это отличный способ, но к несчастью программы бывают настолько запутанны, а программисты настолько невнимательны, что освобождение может не случиться, и тогда мы получим утечку памяти. Либо же наоборот, освобождение может случиться преждевременно, и тогда мы получим ссылку на несуществующие данные.

Сборщики мусора

В таких языках, как JavaScript или Python, освобождением памяти занимаются т.н. сборщики мусора. Программисту не нужно об этом думать. Как только память становится не нужна, она освобождается автоматически. Но как сборщик мусора понимает, что она не нужна?

Вернемся к примеру с указателем. Первоначально указатель на объект имели две переменных: a и b. После того как b исчезла, исчез и указатель, который был в b. Можно ли освободить память, выделенную под объект?

Нет, потому что есть ещё один указатель, который на него указывает. Он хранится в переменной a. Если мы изменим значение переменной, например:

a = null;

То последний указатель на объект исчезнет. Больше нет таких переменных, которые указывают на этот объект. Он повис в пустоте, изолирован от всего.

Таким образом, сборщик мусора должен вести учёт всех указателей, которые указывают на выделенную область памяти. Как только количество указателей становится равным нулю, эту память можно освобождать.

Сборщики мусора работают достаточно хорошо и очень сильно облегчают труд программистов. Но и у них есть недостатки:

  • они замедляют работу программы и временами порождают странные эффекты
  • если в сборщике мусора есть баг, то проблему утечки памяти не решить никак

Что предлагает Rust?

Наконец-то мы добрались до темы выпуска :)

Rust в целом следует всем вышеизложенным принципам, но со своими особенностями. Он не имеет сборщика мусора, как JavaScript, но также не заставляет программиста явно выделять и освобождать память, как C.

Достигается это очень тщательным проектированием самого языка. Например, ситуация, когда двe переменные содержат один и тот же объект, невозможна. Посмотрим такой код:

-7

Мы получим ошибку уже на этапе компиляции. После того, как мы присвоили b = a, значение a полностью перешло во владение b, а переменная a стала невалидной. Это абсолютно неожиданно для программиста, ранее писавшего на других языках.

На деле всё объяснимо. Так как писать об этом ещё много, то остаток я переношу на следующий выпуск, где речь уже пойдёт строго о Rust.

Читайте дальше: Подведение итогов про память