Модель памяти 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 для профи.
Читайте также:
- Основы Go: Go-процедуры (goroutines)
- Основы Go: каналы
- Эффективный Go: параллелизм, go-процедуры (goroutines)
Комментариев нет:
Отправить комментарий