воскресенье, 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.

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


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


Комментариев нет:

Отправить комментарий