# Глава V. Стековая память

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

Однако, для программ, которые работают днями, месяцами и даже годами, память становится ограниченным и весьма ценным ресурсом, который нужен далеко не только какой-то отдельно взятой программе, но и другим, исполняющимся на этой же машине. То есть попросту невозможно ждать, пока какая-то программа не завершится и память станет доступной. Многие языки программирования имеют среду времени выполнения, в которую входит сборщик мусора - его первейшая задача как раз и есть отслеживание более не нужной памяти и её освобождение. Как уже пару раз упоминалось в предыдущих главах, в Zig нет сборщика мусора, поэтому его роль выполняет разработчик программы (это и называется ручное управление памятью).

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

Вторая область памяти это стек, и это тема данной главы. Третья область это динамическая память (heap, "куча"), её мы рассмотрим в следующей главе. Эти три области это не какие-то физически разные виды памяти, это логически области единой оперативной памяти, управление которыми возложено на операционную систему вкупе с модулем управления памятью, входящим в состав процессора.

# Стековые кадры

Все данные, с которыми мы работали до настоящего момента, хранились или в сегменте данных или в локальных переменных. "Локальная" означает, что эта переменная доступна только в той области видимости, где она была объявлена. В Zig области видимости это, по сути, блоки кода, окружённые фигурными скобками ({ ... }). Большинство переменных привязаны или к функции (включая её аргументы), или к управляющим конструкциям, таким как, например, if. Однако, как мы видели, можно создавать любые блоки и тем самым, произвольные области видимости.

В предыдущей главе мы визуализировали содержимое памяти для функций main и levelUp, используя экземпляры переменной типа User:

main: user ->    -------------  (id: 1043368d0)
                 |     1     |
                 -------------  (power: 1043368d8)
                 |    100    |
                 -------------  (name.len: 1043368dc)
                 |     4     |
                 -------------  (name.ptr: 1043368e4)
                 | 1182145c0 |-------------------------
levelUp: user -> -------------  (id: 1043368ec)       |
                 |     1     |                        |
                 -------------  (power: 1043368f4)    |
                 |    100    |                        |
                 -------------  (name.len: 1043368f8) |
                 |     4     |                        |
                 -------------  (name.ptr: 104336900) |
                 | 1182145c0 |-------------------------
                 -------------                        |
                                                      |
                 .............  empty space           |
                 .............  or other data         |
                                                      |
                 -------------  (1182145c0)        <---
                 |    'G'    |
                 -------------
                 |    'o'    |
                 -------------
                 |    'k'    |
                 -------------
                 |    'u'    |
                 -------------

Имеется причина, по которой данные для levelUp идут сразу же после данных для main: это наш (упрощённый) стек вызовов. Когда программа стартует, адрес функции main, а также её локальные переменные "заталкиваются" на стек. Далее, когда вызывается функция levelUp, под её параметры и локальные переменные также отводится место в стеке. Когда levelUp возвращается в точку вызова, она освобождает часть стека, которая ей была нужна. После того, как levelUp завершится, наш стек вызовов будет выглядеть так:

main: user ->    -------------  (id: 1043368d0)
                 |     1     |
                 -------------  (power: 1043368d8)
                 |    100    |
                 -------------  (name.len: 1043368dc)
                 |     4     |
                 -------------  (name.ptr: 1043368e4)
                 | 1182145c0 |-------------------------
                 -------------
                                                      |
                 .............  empty space           |
                 .............  or other data         |
                                                      |
                 -------------  (1182145c0)        <---
                 |    'G'    |
                 -------------
                 |    'o'    |
                 -------------
                 |    'k'    |
                 -------------
                 |    'u'    |
                 -------------

Когда вызывается функция, в стеке выделяется место под весь её стековый кадр. Это одна из причин, по которой мы должны знать размер каждого типа. Когда функция возвращается, место в стеке, выделенное под её стековый кадр, освобождается. Здесь происходит кое-что весьма примечательное: память, используемая функцией levelUp, автоматически освобождается. Хотя чисто технически эту свободную память можно вообще отдать обратно в распоряжение ОС, но обычно область, выделенная под стек, никогда не уменьшается в размерах. Таким образом, память, использованная под стековый кадр функцией levelUp, после завершения работы этой функции становится доступной для использования каким-то другим стековым кадром.

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

Стек, как и сегмент данных, управляется операционной системой. Новый стек вызовов создаётся при запуске процесса, а также при создании этим процессом новых потоков выполнения (нитей, threads). Размер стека обычно можно настроить. Стек вызовов существует всё время работы процесса или нити. Если процесс/нить завершается, память занимаемая их стеками, освобождается.

Выделение памяти на стеке и её освобождение это очень быстрые операции, поскольку это всего лишь декремент/инкремент указателя вершины стека (обычно стек "растёт" в сторону меньших адресов памяти).

# "Висячие" указатели (dangling pointers)

Стек вызовов это замечательный механизм в силу его простоты и эффективности. Однако нас должно насторожить следующее обстоятельство: когда функция завершает работу, всё её локальные переменные как бы исчезают. Это, по идее, разумно, в конце концов, это же локальные переменные, они нужны только самой функции, но есть одно но. Рассмотрим следующий пример:

const std = @import("std");

pub fn main() void {
    var user1 = User.init(1, 10);
    var user2 = User.init(2, 20);

    std.debug.print("User {d} has power of {d}\n", .{user1.id, user1.power});
    std.debug.print("User {d} has power of {d}\n", .{user2.id, user2.power});
}

pub const User = struct {
    id: u64,
    power: i32,

    fn init(id: u64, power: i32) *User{
        var user = User{
            .id = id,
            .power = power,
        };
        return &user;
    }
};

При беглом взгляде, вроде как ожидаемо увидеть такой вывод:

User 1 has power of 10
User 2 has power of 20

Однако, при запуске мы можем увидеть нечто совершенно иное:

$ /opt/zig-0.11/zig run src/ex-ch05-01.zig 
User 2 has power of 20
User 2 has power of 2

Что-то явно пошло совсем не так, как задумывалось. Более того, если мы соберём нашу программу вот таким образом (то есть с оптимизацией по размеру):

/opt/zig-0.11/zig build-exe src/ex-ch05-01.zig -O ReleaseSmall

то мы вообще можем увидеть какую-то несусветную чушь:

$ ./ex-ch05-01 
User 2105973 has power of 704957192
User 16777216 has power of -1

Почему так происходит? Дело в том, что функция User.init возвращает адрес локальной переменной, то есть &user. Это называется "висячий" указатель, источник многих проблем, вплоть до аварийного завершения или, по крайней мере, как мы только что видели, явно некорректного поведения программы.

Когда стековый кадр "уничтожается", всякие ссылки в ту область памяти, которую этот кадр занимал, становятся попросту бессмысленными. И что мы получим при обращении по таким указателям, совершенно не определено, может получиться всё что угодно - или мы получим какие-то абсурдные данные или, в совсем тяжких случаях, аварийное завершение программы (segfault).

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

Кстати, в языках со сборкой мусора такой код может вполне себе правильно работать. Например, в Go компилятор может определить, что локальный объект user должен "жить" дольше, чем до конца работы функции, то есть до конца своей области видимости и сделать так, чтобы он был доступен и после завершения функции до тех пор, пока он действительно нужен. Как конкретно это делается, зависит от деталей реализации, в Go есть несколько возможностей, включая перенос нужных данных в кучу.

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

fn read() !void {
    const input = try readUserInput();
    return Parser.parse(input);
}

Видите потенциальные проблемы? То, что возвращает Parser.parse, чем бы оно не являлось, явно "переживает" (outlives) input. Если Parser содержит ссылку на input, то это будет висячей ссылкой, которая будет коварно ждать, чтобы обрушить нашу программу. В идеале, если переменной типа Parser нужен input, который будет существовать столь же долго, как и она сама, Parser сделает копию и эта копия будет привязана к его собственному времени жизни (подробнее об этом будет в следующей главе). Но тут нет ничего, что способствовало бы соблюдению такого соглашения. Возможно, гипотетическая документация к типу Parserможет прояснить, что он ожидает от input или что он с ним делает. Если же документации нет, то, скорее всего, нам придётся углубиться в код, чтобы выяснить всё это.

Простой путь решить нашу изначальную проблему это сделать так, чтобы init возвращала User, а не *User, тогда бы могли написать return user, а не return &user. Но это не всегда оказывается возможным. Часто данные должны "жить" между жёсткими границами областей видимости внутри функций. Для это мы должны использовать третью область памяти, кучу, тему следующей главы.

Прежде чем погрузиться в тему динамического выделения памяти, небольшое забегание вперёд: в этой книге вы увидите ещё один пример висячих указателей. К тому моменту мы уже будем знать достаточно, чтобы привести менее надуманный пример. К этой теме важно будет вернуться потому, что для разработчиков, которые раньше не имели дела с языками без автоматической сборки мусора (например, C), ручное управление памятью может поначалу показаться чем-то трудным, что чревато ошибками в программах и последующим отчаянием после попыток исправить программу. Но не надо отчаиваться заранее, вы получите то, что позволит лучше понять ручное управление память, а пока мы скажем, что ключевой момент это чётко осознавать, где и когда существуют данные.