Языковая Механика Стеков И Указателей



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

Содержание цикла статей:

  1. Языковая механика стеков и указателей
  2. Языковая механика анализа побега ( перевод )
  3. Языковая механика профилирования памяти ( перевод )
  4. Философия проектирования данных и семантики


Введение

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

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

Это особенно актуально при написании параллельных или многопоточных программ.

Неудивительно, что многие языки пытаются скрыть указатели от программистов.

Однако, если вы пишете на 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 запускается, среда выполнения создает основную горутину, чтобы начать выполнение всего кода, включая код внутри основной функции.

Горутина — это путь выполнения, который размещается в потоке операционной системы, который в конечном итоге выполняется на каком-то ядре.

Начиная с версии 1.8, каждой горутине предоставляется начальный блок непрерывной памяти размером 2048 байт, который образует пространство стека.

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

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

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

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

На рисунке 1 вы можете видеть, что часть стека «оформлена» для основной функции.

Этот раздел называется " стек кадров ", и именно этот кадр отмечает границу основной функции в стеке.

Фрейм настроен как фрагмент кода, который выполняется при вызове функции.

Вы также можете видеть, что память для переменной count была выделен по адресу 0x10429fa4 внутри кадра для основного.

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

Вам необходимо четко понимать границу между допустимой и недействительной частью стека.



Адреса

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

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

В строке 09 основная функция вызывает встроенную функцию 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 основная функция вызывает функцию приращения.

Листинг 4:

12 increment(count)

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

Однако все немного сложнее.

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

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

Вы можете увидеть это требование, просмотрев объявление функции приращения в строке 18. Листинг 5:

18 func increment(inc int) {

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

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

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

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

Вы можете видеть, что в стеке теперь два кадра — один для основного и один для увеличения.

Внутри рамки приращения вы можете увидеть переменную inc, содержащую значение 10, которое было скопировано и передано во время вызова функции.

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

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

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

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

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

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

После выполнения строк 21 и 22 функция приращения завершает работу и возвращает управление основной функции.

Затем основная функция снова отображает «значение» и «адрес» счетчика локальной переменной в строке 14. Листинг 8:

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

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

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

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



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

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

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

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

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

Это связано с тем, что основной кадр теперь активен.

Память, созданная для функции приращения, остается нетронутой.

Очистка памяти возвращаемого кадра функции будет пустой тратой времени, поскольку неизвестно, понадобится ли эта память когда-нибудь снова.

Так что память осталась такой, какая была.

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

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

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



Общие ценности

Что, если бы было важно, чтобы функция приращения работала непосредственно с переменной count, которая существует внутри фрейма для main? Вот тут и приходит время указателей.

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

Если вы не чувствуете необходимости «разделять» значение, вам не нужно использовать указатель.

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

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



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

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

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

Во-первых, они начинаются с символа *.

Во-вторых, все они имеют одинаковый размер и представление памяти, занимая 4 или 8 байт, представляющих адрес.

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

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



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

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

Это разделит переменную count из стека основного кадра с помощью функции приращения: Листинг 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 с помощью функции приращения.

Именно это и означает оператор &: «делиться».

Помните, что это по-прежнему «передача по значению», и единственное отличие состоит в том, что передаваемое значение является адресом, а не целым числом.

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

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

Объявление целочисленной переменной-указателя находится в строке 18. Листинг 12:

18 func increment(inc *int) {

Если бы вы передавали адрес значения типа User, то переменную необходимо было бы объявить как *User. Хотя все переменные указателя хранят значения адреса, им нельзя передавать какой-либо адрес, а только адреса, связанные с типом указателя.

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

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

Компилятор позаботится о том, чтобы с этой функцией использовались только значения, связанные с правильным типом указателя.

Вот как выглядит стек после вызова инкремента: Рисунок 5:

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

На рис.

5 показано, как выглядит стек, когда «передача по значению» выполняется с использованием адреса в качестве значения.

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

21 *inc++

На этот раз символ * действует как оператор и применяется к переменной-указателю.

Использование * в качестве оператора означает «значение, на которое указывает указатель».

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

Функция приращения по-прежнему должна иметь в своем кадре переменную-указатель, которую она может напрямую прочитать для выполнения косвенного доступа.

На рис.

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 совпадает с «адресом» переменной-счета.

Это устанавливает отношения совместного использования, которые позволяют косвенный доступ к памяти за пределами кадра.

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



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

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

У них есть выделение памяти и они содержат значение.

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

Что может сбить с толку, так это то, что символ * действует как оператор внутри кода и используется для объявления типа указателя.

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



Заключение

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

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

  • Функции выполняются внутри границ кадра, которые предоставляют отдельное пространство памяти для каждой соответствующей функции.

  • При вызове функции происходит переход между двумя кадрами.

  • Преимущество передачи данных «по значению» — удобочитаемость.

  • Стек важен, поскольку он обеспечивает пространство физической памяти для границ кадра, присвоенных каждой отдельной функции.

  • Вся стековая память ниже активного кадра недействительна, но память от активного кадра и выше действительна.

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

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

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

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

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

    У них есть выделение памяти и они содержат значение.

Теги: #программирование #Go #профилирование #куча #escape-анализ #память #стек #индекс
Вместе с данным постом часто просматривают: