diff --git a/src/ch05.md b/src/ch05.md index d1b8e2b..ebaefcd 100644 --- a/src/ch05.md +++ b/src/ch05.md @@ -1,7 +1,283 @@ # Стековая память +Рассмотрение указателей в предыдущей ознакомило нас с взаимосвязью между +переменными, данными и памятью. Но нам ещё нужно поговорить о том, как +происходит управление ланными и памятью. Для коротко-живущих и простых +скриптов это, как правило, не имеет особого значения. В наше время, имея +ноутбук, оснащённый 32-мя гигабайтами оперативной памяти, Вы можете +запустить свою программу, которая, делая какую-то работу (например, +прочитать файл), может запросто использовать несколько сотен мегабайт +памяти, сделать что-то потрясающее и далее выйти. По выходу операционная +система освобождает все ресурсы (память, в частности), которые были +исопльзованы программойЮ пока она выполнялась. Освобожденная память затем +может быть отдана в пользование другим программам. + +Однако, для программ, которые работают днями, месяцами и даже годами, +память становится ограниченным и весьма ценным ресурсом, который нужен +далеко не только какой-то отдельно взятой программе, но и другим, +исполняющимся на этой же машине. То есть попросту невозможно ждать, пока +какая-то программа не завершится и память станет доступной. Многие языки +программиования имеют среду времени выполнения, в которую входит сборщик +мусора - его первейшая задача как раз и есть отслеживание более не нужной +памяти и её освобождение. Как уже пару раз упоминалось в предыдущих +главах, в Zig нет сборщика мусора, поэтому его роль выполняет разработчик +программы (это и называется ручное управление памятью). + +Большинство программ используют 3 области памяти. Первая из них это такая +область, где хранятся данные, не подлежащие модификации, то есть +константы, включая строковые. Эти данные содержатся в исполнимом файле. +Такие данные существуют (то есть занимают память) всё время выполнения +программы, причём объём занимаемой имм памяти остаётся постоянным, он не +может ни увеличиться, ни уменьшится. Но это не то, о чём нас следует +беспокоиться, разве что общее количество таких данных сказывается на +размере исполнимого файла. + +Вторая область памяти это стек, и это тема данной главы. Третья область +это динамическая память (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) +Стек вызовов это замечательный механизм в силу его простоты и эффективности. +Однако нас должно насторожить следующее обстоятельство: когда функция +завершает работу, всё её локальные переменные как бы исчезают. +Это, по идее, разумно, в конце концов, это же лоакльные переменные, +они нужны только самой функции, но есть одно но. Рассмотрим следующий пример: + +```zig +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 +``` + +то мы вообще можем увидеть какую-то несусветную чушь: + +``` +s$ ./ex-ch05-01 +User 2105973 has power of 704957192 +User 16777216 has power of -1 +``` + +Почему так происходит? Дело в том, что функция `User.init` возвращает +**адрес локальной переменной**, то есть `&user`. Это называется "висячий" +указатель, источник многих проблем, вплоть до аварийного завершения или, +по крайней мере, как мы только что виделм, явно некорректное поведения программы. + +Когда стековый кадр "уничтожается", всякие ссылки в ту область памяти, +которую этот кадр занимал, становятся попросту бессмысленными. И что мы +получим при обращении по таким указателям, совершенно не определено, +может получиться всё что угодно - или мы получим какие-то абсурдные данные или, в +совсем тяжких случаях, аварийное завершение программы (segfault). + +Мы могли бы попытаться придать какой-то смысл тем данным, которые вывела +наша программа, но мы вовсе не хотим этого делать, тут нет никакого +смысла разбираться, почему напечатались именно такие числа - мы просто +допустили очень нехорошую (и по сути, глупую) ошибку. + +Кстати, в языках со сборкой мусора такой код может вполне себе правильно работать. +Например, в Go компилятор может определить, что локальный объект `user` +должен "жить" дольше, чем до конца работы функции, то есть до конца +своей области видимости и сделать так, чтобы он был доступен и после +завершения функции до тех пор, пока он действительно нужен. Как конкретно +это делается, зависит от деталей реализации, в Go есть несколько возможностей, +включая перенос нужных данных в кучу. + +Что особенно неприятно с указателями, которые показывают туда, куда бы +уже не надо показывать, так это то, что подобного рода ошибки могут +быть очень и очень трудными для обнаружения. В нашем примере ясно +видно, что мы возвращаем адрес локальной переменной. Но посмотрите +на этот отрывок кода: + +```zig +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`. Но это не всегда оказывается возможным. Часто +данные должны "жить" между жёсткими границами областей видимости внутри +функций. Для это мы должны использовать 3-ю область памяти, кучу, тему +следующей главы. + +Прежде чем погрузиться в тему динамического выделения памяти, небольшое +забегание вперёд: в этой книге Вы увидите ещё один пример висячих +указателей. К тому моменту мы уже будем знать достаточно, чтобы привести +менее ... пример. К этой теме важно будет вернуться потому, что для +разработчиков, которые раньше не имели дела с языками без автоматической +сборки мусора (например, C), ручное управление памятью может поначалу +показаться чем-то трудным, что чревато ошибками в программах и +последующим отчаянием после попыток исправить программу. Но не надо +отчаиваться заранее, Вы получите то, что позволит лучше понять ручное +управление память, а пока мы скажем, что ключевой момент это чётко +осознавать, где и когда существуют данные. diff --git a/src/ex-ch05-01.zig b/src/ex-ch05-01.zig new file mode 100644 index 0000000..9f0fbb0 --- /dev/null +++ b/src/ex-ch05-01.zig @@ -0,0 +1,23 @@ + +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; + } +};