Показаны сообщения с ярлыком память в Go. Показать все сообщения
Показаны сообщения с ярлыком память в Go. Показать все сообщения

пятница, 27 сентября 2024 г.

Где в памяти располагаются переменные в Golang

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

На стеке

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

func main() {
    var myVar string // Переменная 'myVar' будет находиться на стеке
}

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

В куче (heap)

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

Глобальные переменные

package main

var myVar string // Переменная 'myVar' будет находиться в куче

func main() {}

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

Создание переменной через new

package main

import "fmt"

func main() {
    var myVar *string = new(string) // Переменная 'myVar' будет находиться в куче
    fmt.Println(*myVar)             // Результат будет nil
    
    *myVar = "Hello, World!"        // Заполнение значения по адресу переменной
    fmt.Println(*myVar)             // Вывод: Hello, World!
}

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

Таким образом, чтобы определить, где именно в памяти находится переменная, нужно проанализировать её использование в коде:

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

Читайте также:


Как Golang работает с памятью

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

Автоматическое управление памятью

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

Память, выделенная в стеке

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

Динамическая память (куча, heap)

Переменные, созданные с помощью оператора new, размещаются в куче. Куча используется для хранения объектов, которые должны существовать дольше одной функции. Управление памятью в куче осуществляется через сборщик мусора, который периодически сканирует память и освобождает объекты, на которые больше нет ссылок.

Сборка мусора

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

Безопасность памяти

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

Высокая производительность

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


Читайте также:


воскресенье, 29 сентября 2019 г.

Модель памяти Go

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

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

Чтобы сериализовать доступ, защитите данные с помощью операций канала или других примитивов синхронизации, таких как в пакетах sync и sync/atomic.

Происходит до

В рамках одной go-процедуры чтения и записи должны вести себя так, как если бы они выполнялись в порядке, указанном программой. То есть компиляторы и процессоры могут переупорядочивать операции чтения и записи, выполняемые в пределах одной go-процедуры, только когда переупорядочение не изменяет поведение в этой go-процедуре, как определено в спецификации языка. Из-за этого переупорядочения порядок выполнения, наблюдаемый одной go-процедурой, может отличаться от порядка, воспринимаемого другой. Например, если одна go-процедура выполняет a = 1; b = 2; другая может наблюдать обновленное значение b перед обновленным значением a.

Чтобы указать требования для чтения и записи, мы определяем, что происходит до, частичный порядок выполнения операций с памятью в программе Go. Если событие e1 происходит до события e2, то мы говорим, что e2 происходит после e1. Кроме того, если e1 не происходит до e2 и не происходит после e2, то мы говорим, что e1 и e2 происходят конкурентно.

В пределах одной go-процедуры порядок "происходит до" - это порядок, выраженный программой.

Чтение r переменной v может наблюдать запись w в v, если выполняются оба следующих условия:

  • r не происходит до w.
  • Нет другой записи w` в v, которая происходит после w, но до r.

Чтобы гарантировать, что чтение r переменной v наблюдает конкретную запись w в v, убедитесь, что w - единственная запись, которую r позволено наблюдать. То есть r гарантированно наблюдает w, если выполняются оба следующих условия:

  • w происходит до r.
  • Любая другая запись в разделяемую переменную v происходит до w или после r.

Эта пара условий сильнее первой пары; это требует, чтобы не было никаких других записей, происходящих одновременно с w или r.

Внутри одной go-процедуры нет конкурентности, поэтому два определения эквивалентны: чтение r наблюдает значение, записанное самой последней записью w в v. Когда несколько go-процедур обращаются к общей переменной v, они должны использовать события синхронизации, чтобы установить условия "происходит до", обеспечивающие что чтение наблюдает желаемые записи.

Инициализация переменной v с нулевым значением для ее типа ведет себя как запись в модели памяти.

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

Синхронизация


Инициализация

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

Если пакет p импортирует пакет q, завершение init функций q происходит до начала любого из p.

Запуск функции main.main происходит после завершения всех init функций.

Создание go-процедуры

Оператор go, который запускает новую go-процедуру, происходит до того, как начинается выполнение go-процедуры.

Например, в этой программе:

var a string

func f() {
 print(a)
}

func hello() {
 a = "hello, world"
 go f()
}

вызов hello напечатает "hello, world" в какой-то момент в будущем (возможно, после возвращения hello).

Разрушение go-процедуры

Не гарантируется, что выход из go-процедуры происходит до какого-либо события в программе. Например, в этой программе:

var a string

func hello() {
 go func() { a = "hello" }()
 print(a)
}

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

Если эффекты go-процедуры должны быть наблюдаемы другой go-процедурой, используйте механизм синхронизации, такой как замок (lock) или канал связи, чтобы установить относительное упорядочение.

Связь по каналу

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

Отправка по каналу происходит до того, как завершится соответствующий прием с этого канала.

Эта программа:

var c = make(chan int, 10)
var a string

func f() {
 a = "hello, world"
 c <- 0
}

func main() {
 go f()
 <-c
 print(a)
}

гарантированно будет печатать "hello, world". Запись в a происходит до отправки по c, что происходит до завершения соответствующего приема по c, что происходит перед печатью.

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

В предыдущем примере замена c <- 0 на close(c) приводит к программе с таким же гарантированным поведением.

Прием из небуферизованного канала происходит до того, как отправка по этому каналу завершается.

Эта программа (как указано выше, но с помененными местами утверждений отправки и получения и использованием небуферизованного канала):

var c = make(chan int)
var a string

func f() {
 a = "hello, world"
 <-c
}
func main() {
 go f()
 c <- 0
 print(a)
}

также гарантированно будет печатать "hello, world". Запись в a происходит до получения по c, что происходит до завершения соответствующей отправки по c, что происходит перед печатью.

Если бы канал был буферизован (например, c = make(chan int, 1)), то программе не гарантировалась бы печать "hello, world". (Она может напечатать пустую строку, обрушиться, или сделать что-то еще.)

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

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

Эта программа запускает go-процедуру для каждой записи в work списке, но эти go-процедуры координируются использованием limit канала, чтобы гарантировать, что не более трех одновременно выполняют work функции.

var limit = make(chan int, 3)

func main() {
 for _, w := range work {
  go func(w func()) {
   limit <- 1
   w()
   <-limit
  }(w)
 }
 select{}
}

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

Замки (locks)

Пакет sync реализует два типа данных замков, sync.Mutex и sync.RWMutex.

Для любой sync.Mutex или sync.RWMutex переменной l при n < m вызов n из l.Unlock() происходит до возврата вызова m из l.Lock().

Эта программа:

var l sync.Mutex
var a string

func f() {
 a = "hello, world"
 l.Unlock()
}

func main() {
 l.Lock()
 go f()
 l.Lock()
 print(a)
}

гарантированно будет печатать "hello, world". Первый вызов l.Unlock() (в f) происходит до того, как второй вызов l.Lock() (в main) возвращается, что происходит до печати.

Для любого вызова l.RLock sync.RWMutex переменной l существует такое n, что l.RLock происходит (возвращается) после вызова n для l.Unlock, и соотвествующий l.RUnlock происходит до вызова n + 1 для l.Lock.

Once (единожды)

Пакет sync обеспечивает безопасный механизм для инициализации при наличии нескольких go-процедур благодаря использованию типа Once. Несколько потоков могут выполнять once.Do(f) для определенного f, но только один из них будет запускать f(), а остальные вызовы будут блокироваться до тех пор, пока f() не вернется.

Один вызов функции f() из once.Do(f) происходит (возвращается) до того как любой вызов once.Do(f) возвращается.

В этой программе:

var a string
var once sync.Once

func setup() {
 a = "hello, world"
}

func doprint() {
 once.Do(setup)
 print(a)
}

func twoprint() {
 go doprint()
 go doprint()
}

вызов twoprint вызовет setup ровно один раз. Функция setup завершится до любого вызова печати. В результате "hello, world" будет напечатано дважды.

Направильная синхронизация

Обратите внимание, что чтение r может наблюдать значение, записанное записью w, которое происходит одновременно с r. Даже если это происходит, это не означает, что чтения происходящие после r будут наблюдать записи, которые произошли до w.

В этой программе:

var a, b int

func f() {
 a = 1
 b = 2
}

func g() {
 print(b)
 print(a)
}

func main() {
 go f()
 g()
}

может случиться так, что g напечатает 2, а затем 0.

Этот факт лишает законной силы несколько общих идиом.

Двойная проверка блокировки - это попытка избежать накладных расходов на синхронизацию. Например, программа twoprint может быть неправильно написана как:

var a string
var done bool

func setup() {
 a = "hello, world"
 done = true
}

func doprint() {
 if !done {
  once.Do(setup)
 }
 print(a)
}

func twoprint() {
 go doprint()
 go doprint()
}

но нет никакой гарантии, что в doprint наблюдение за записью в done подразумевает наблюдение за записью в a. Эта версия может (неправильно) печатать пустую строку вместо "hello, world".

Другая неправильная идиома занята ожиданием значения, как в:

var a string
var done bool

func setup() {
 a = "hello, world"
 done = true
}

func main() {
 go setup()
 for !done {
 }
 print(a)
}

Как и раньше, нет гарантии, что в main наблюдение за записью в done подразумевает наблюдение за записью в a, поэтому эта программа может также напечатать пустую строку. Хуже того, нет никакой гарантии, что запись в done когда-либо будет выполняться main, поскольку нет никаких событий синхронизации между двумя потоками. Не гарантируется что цикл в main завершится.

На эту тему есть более тонкие варианты, такие как эта программа.

type T struct {
 msg string
}

var g *T

func setup() {
 t := new(T)
 t.msg = "hello, world"
 g = t
}

func main() {
 go setup()
 for g == nil {
 }
 print(g.msg)
}

Даже если main наблюдает за g != nil и выходит из своего цикла, нет гарантии, что он будет наблюдать инициализированное значение для g.msg.

Во всех этих примерах решение одно и то же: используйте явную синхронизацию.

Подробней о работе с памятью можно прочесть в книге Golang для профи.


Читайте также:


купить игрушку gopher