Как стать автором
Обновить

Языковая механика стеков и указателей

Время на прочтение10 мин
Количество просмотров10K
Автор оригинала: William Kennedy

Прелюдия


Это первая из четырех статей в серии, которая даст представление о механике и дизайне указателей, стеков, куч, escape analysis и семантики значения/указателя в Go. Этот пост посвящен стекам и указателям.

Оглавление цикла статей:

  1. Language Mechanics On Stacks And Pointers
  2. Language Mechanics On Escape Analysis (перевод)
  3. Language Mechanics On Memory Profiling (перевод)
  4. Design Philosophy On Data And Semantics

Вступление


Не буду лукавить — указатели трудны в понимании. При неправильном использовании указатели могут вызвать неприятные ошибки и даже проблемы с производительностью. Это особенно верно при написании конкурентных или многопоточных программ. Неудивительно, что многие языки пытаются скрыть указатели от программистов. Однако, если вы пишете на Go, вы не сможете избежать указателей. Без четкого понимания указателей вам будет сложно писать чистый, простой и эффективный код.

Границы фреймов


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

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

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

Посмотрите на эту небольшую программу, которая выполняет вызов функции, передавая целочисленные данные «по значению»:

Листинг 1:

01 package main
02
03 func main() {
04
05    // Declare variable of type int with a value of 10.
06    count := 10
07
08    // Display the "value of" and "address of" count.
09    println("count:\tValue Of[", count, "]\tAddr Of[", &count, "]")
10
11    // Pass the "value of" the count.
12    increment(count)
13
14    println("count:\tValue Of[", count, "]\tAddr Of[", &count, "]")
15 }
16
17 //go:noinline
18 func increment(inc int) {
19
20    // Increment the "value of" inc.
21    inc++
22    println("inc:\tValue Of[", inc, "]\tAddr Of[", &inc, "]")
23 }

Когда ваша программа, написанная на Go, запускается, среда выполнения создает главную горутину, чтобы начать выполнение всего кода, включая код внутри функции main. Горутина — это путь выполнения, который помещается в поток операционной системы, который в конечном итоге выполняется на каком-то ядре. Начиная с версии 1.8, каждой горутине предоставляется начальный блок непрерывной памяти размером 2048 байт, который формирует пространство стека. Этот начальный размер стека менялся с годами и может измениться в будущем.

Стек важен, потому что он обеспечивает пространство физической памяти для границ фрейма, которые даны каждой отдельной функции. К тому времени, когда главная горутина выполняет функцию main в листинге 1, стек программы (на очень высоком уровне) будет выглядеть следующим образом:

Рисунок 1:



На рисунке 1 вы можете увидеть, что часть стека была «обрамлена» для основной функции. Этот раздел называется «стековым фреймом», и именно этот фрейм обозначает границу основной функции в стеке. Фрейм устанавливается как часть кода, которая выполняется при вызове функции. Вы также можете увидеть, что память для переменной count была размещена по адресу 0x10429fa4 внутри фрейма для main.

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

Адреса


Переменные служат для назначения имени определенной ячейке памяти, чтобы улучшить читаемость кода и помочь вам понять с какими данными вы работаете. Если у вас есть переменная, значит у вас есть значение в памяти, а если у вас есть значение в памяти, то у нее должен быть адрес. В строке 09 функция main вызывает встроенную функцию println для отображения «значения» и «адреса» переменной count.

Листинг 2:

09    println("count:\tValue Of[", count, "]\tAddr Of[", &count, "]")

Использование амперсанда “&” для получения адреса расположения переменной не является чем-то новым, другие языки также используют этот оператор. Вывод строки 09 должен быть похож на вывод ниже, если вы запускаете код на 32-битной архитектуре, такой как Go Playground:

Листинг 3:

count:  Value Of[ 10 ]  Addr Of[ 0x10429fa4 ]

Вызов Функций


Далее в строке 12 функция main вызывает функцию increment.

Листинг 4:

12    increment(count)

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

Листинг 5:

18 func increment(inc int) {

Если вы снова посмотрите на вызов функции increment в строке 12, вы увидите, что код передает «значение» переменной count. Это значение будет скопировано, передано и помещено в новый фрейм для функции increment. Помните, что функция increment может только непосредственно читать и записывать в память в своем собственном фрейме, поэтому ей нужна переменная inc для получения, хранения и доступа к своей собственной копии передаваемого значения счетчика.

Непосредственно перед тем, как код внутри функции increment начнет выполняться, стек программы (на очень высоком уровне) будет выглядеть так:

Рисунок 2:



Вы можете увидеть, что в стеке теперь есть два фрейма — один для main и ниже один для increment. Внутри фрейма для increment видно переменную inc, содержащую значение 10, которое было скопировано и передано во время вызова функции. Адрес переменной inc равен 0x10429f98, и он меньше в памяти, потому что фреймы заносятся в стек, что является лишь деталями реализации, которые ничего не значат. Важно то, что программа извлекла значение count из фрейма для main и поместила копию этого значения во фрейм для увеличения с помощью переменной inc.

Остальная часть кода внутри increment увеличивает и отображает «значение» и «адрес» переменной inc.

Листинг 6:

21    inc++
22    println("inc:\tValue Of[", inc, "]\tAddr Of[", &inc, "]")

Вывод строки 22 в playground должен выглядеть примерно так:

Листинг 7:

inc:    Value Of[ 11 ]  Addr Of[ 0x10429f98 ]

Вот как выглядит стек после выполнения тех же строк кода:

Рисунок 3:



После выполнения строк 21 и 22 функция increment завершается и возвращает управление main функции. Затем main функция снова отображает «значение» и «адрес» локальной переменной count в строке 14.

Листинг 8:

14    println("count:\tValue Of[",count, "]\tAddr Of[", &count, "]")

Полный вывод программы в playground должен выглядеть примерно так:

Листинг 9:

count:  Value Of[ 10 ]  Addr Of[ 0x10429fa4 ]
inc:    Value Of[ 11 ]  Addr Of[ 0x10429f98 ]
count:  Value Of[ 10 ]  Addr Of[ 0x10429fa4 ]

Значение count во фрейме для main одинаково до и после вызова increment.

Возврат из функций


Что на самом деле происходит с памятью в стеке, когда функция завершается и управление возвращается к вызывающей функции? Краткий ответ — ничего. Вот как выглядит стек после возврата функции increment:

Рисунок 4:



Стек выглядит точно так же, как на рисунке 3, за исключением того, что фрейм, связанный с функцией increment, теперь считается недействительной памятью. Это потому, что фрейм для main теперь является активным. Память, созданная для функции increment, осталась нетронутой.

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

Совместное использование значений


Что, если бы было важно, чтобы функция increment работала непосредственно с переменной count, которая существует внутри фрейма для main? Именно здесь наступает время для указателей. Указатели служат одной цели — разделять значение с функцией, чтобы функция могла читать и записывать это значение, даже если значение не существует непосредственно внутри своего фрейма.

Если вам не кажется, что нужно «поделиться» значением, то вам и не нужно использовать указатель. При изучении указателей важно думать, что используя чистый словарь, а не операторы или синтаксис. Помните, что указатели предназначены для совместного использования и при чтении кода заменяют оператор & на словосочетание «общий доступ».

Типы указателей


Для каждого типа, который вы объявили, или который был объявлен непосредственно самим языком, вы получаете бесплатный тип указателя, который вы можете использовать для совместного использования. Уже существует встроенный тип с именем int, поэтому существует тип указателя с именем *int. Если вы объявите тип с именем User, вы получите бесплатно тип указателя с именем *User.

Все типы указателей имеют две одинаковые характеристики. Во первых они начинаются с символа *. Во-вторых, все они имеют одинаковый размер в памяти и представление, занимающее 4 или 8 байт, которые представляют адрес. На 32-битных архитектурах (например, в playground) указателям требуется 4 байта памяти, а на 64-битных архитектурах (например, вашем компьютере) они требуют 8 байт памяти.

В спецификации типы указателей считаются литералами типов, что означает, что они являются безымянными типами, составленными из существующего типа.

Косвенный доступ к памяти


Посмотрите на эту небольшую программу, которая выполняет вызов функции, передавая адрес «по значению». Это разделит переменную count из стека фрейма main с функцией increment:

Листинг 10:

01 package main
02
03 func main() {
04
05    // Declare variable of type int with a value of 10.
06    count := 10
07
08    // Display the "value of" and "address of" count.
09    println("count:\tValue Of[", count, "]\t\tAddr Of[", &count, "]")
10
11    // Pass the "address of" count.
12    increment(&count)
13
14    println("count:\tValue Of[", count, "]\t\tAddr Of[", &count, "]")
15 }
16
17 //go:noinline
18 func increment(inc *int) {
19
20    // Increment the "value of" count that the "pointer points to". (dereferencing)
21    *inc++
22    println("inc:\tValue Of[", inc, "]\tAddr Of[", &inc, "]\tValue Points To[", *inc, "]")
23 }

В оригинальную программу были внесены три интересных изменения. Первое изменение находится в строке 12:

Листинг 11:

12    increment(&count)

На этот раз в строке 12 код не копирует и передает «значение» переменной count, а передает вместо переменной count ее «адрес». Теперь вы можете сказать: «Я делю» переменную count с функцией increment. Это то, о чем говорит оператор & — «делиться».

Поймите, что это все еще «передача по значению», а единственная разница в том, что передаваемое вами значение — это адрес, а не целое число. Адреса тоже значения; это то, что копируется и передается через границу фрейма для вызова функции.

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

Листинг 12:

18 func increment(inc *int) {

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

Вот как выглядит стек после вызова функции increment:

Рисунок 5:



На рисунке 5 показано, как выглядит стек, когда «передача по значению» выполняется с использованием адреса в качестве значения. Переменная-указатель внутри фрейма для функции increment теперь указывает на переменную count, которая расположена внутри фрейма для main.

Теперь, используя переменную-указатель, функция может выполнить косвенную операцию чтения и изменения для переменной count, расположенной внутри фрейма для main.

Листинг 13:

21    *inc++

На этот раз символ * действует как оператор и применяется к переменной-указателю. Использование * в качестве оператора означает «значение, на которое указывает указатель». Переменная указателя обеспечивает косвенный доступ к памяти за пределами фрейма функции, которая ее использует. Иногда это косвенное чтение или запись называется разыменованием указателя. Функция increment по-прежнему должна иметь переменную-указатель в своем фрейме, которую она может непосредственно прочитать для выполнения косвенного доступа.

На рисунке 6 показано, как выглядит стек после выполнения строки 21.

Рисунок 6:



Вот конечный вывод результата этой программы:

Листинг 14:

count:  Value Of[ 10 ]              Addr Of[ 0x10429fa4 ]
inc:    Value Of[ 0x10429fa4 ]      Addr Of[ 0x10429f98 ]   Value Points To[ 11 ]
count:  Value Of[ 11 ]              Addr Of[ 0x10429fa4 ]

Вы можете заметить, что «значение» переменной-указателя inc совпадает с «адресом» переменной count. Это устанавливает отношение совместного использования, которое позволило косвенный доступ к памяти за пределами фрейма. Как только функцией increment выполняется запись через указатель, изменение видны для main функции, когда ей возвращается управление.

Переменные-указатели не являются чем-то особенным


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

Вывод


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

В итоге вот что вы узнали:

  • Функции выполняются в пределах границ фрейма, которые предоставляют отдельное пространство памяти для каждой соответствующей функции.
  • Когда вызывается функция, происходит переход между двумя фреймами.
  • Преимущество передачи данных «по значению» заключается в удобочитаемости.
  • Стек важен, потому что он обеспечивает пространство физической памяти для границ фрейма, которые даны каждой отдельной функции.
  • Вся память стека ниже активного фрейма недействительна, но действительна память из активного фрейма и выше.
  • Выполнение вызова функции означает, что программа должна создать новый раздел памяти в стеке.
  • Во время каждого вызова функции, когда берется фрейм, стековая память для этого фрейма очищается.
  • Указатели служат одной цели — разделять значение с функцией, чтобы функция могла читать и изменять это значение, даже если значение не существует непосредственно внутри своего фрейма.
  • За каждый объявленный тип, либо вами, либо самим языком, вы получаете бесплатно тип указателя, который вы можете использовать для разделения.
  • Переменная-указатель обеспечивает косвенный доступ к памяти за пределами фрейма для функции, которая ее использует.
  • Переменные-указатели не являются чем-то особенным, потому что они такие же переменные, как и любая другая переменная. У них есть распределение памяти, и они содержат значение.
Теги:
Хабы:
Всего голосов 8: ↑7 и ↓1+6
Комментарии7

Публикации