On branch main
modified: src/ch05.md new file: src/ex-ch05-01.zig
This commit is contained in:
276
src/ch05.md
276
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), ручное управление памятью может поначалу
|
||||
показаться чем-то трудным, что чревато ошибками в программах и
|
||||
последующим отчаянием после попыток исправить программу. Но не надо
|
||||
отчаиваться заранее, Вы получите то, что позволит лучше понять ручное
|
||||
управление память, а пока мы скажем, что ключевой момент это чётко
|
||||
осознавать, где и когда существуют данные.
|
||||
|
||||
23
src/ex-ch05-01.zig
Normal file
23
src/ex-ch05-01.zig
Normal file
@@ -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;
|
||||
}
|
||||
};
|
||||
Reference in New Issue
Block a user