Ядро параллельного программирования Golang — видимость памяти

задняя часть Go

Публичный номер: облачное хранилище Qiya

[toc]

задний план

Самой большой особенностью языка Go является его высокая способность параллелизма.Горутин-сопрограммы используются в качестве тела выполнения, чтобы в полной мере использовать вычислительную мощность современных процессоров, но механизм параллелизма также создает проблему безопасности параллелизма сопрограмм. Современные процессоры представляют собой многоуровневые структуры кэша, и компилятор будет переупорядочивать и оптимизировать инструкции, а выполнение ЦП также может выполняться не по порядку, так как же гарантировать, что операция записи одного тела выполнения сопрограммы правильно видна другому телу выполнения? Модель памяти Go (Go Memory Model) определяет набор критериев «происходит до», и только программы, которые полагаются на этот критерий, могут гарантировать правильное выполнение параллельной логики.

Что такое видимость памяти?

Видимость памяти Golang, предполагается, что многие программисты Golang сами этого не заметили? На самом деле прямые проявления этого аспекта есть во многих языках, и в Голанге он естественно есть, так что же такое видимость памяти? Говоря простым языком, результат операции виден окружающим. Обычно мы обсуждаем этот вопрос только в параллельной среде.В среде с одним исполнителем инструкции, выполняемые впереди, видны инструкциям позади.

Например:

a := 1          // A
b := 2          // B
c := a + b      // C

Вышеприведенный код, после оптимизации компилятора и компиляции и внеочередного выполнения процессора, порядок выполнения может быть не (A, B, C), а может быть (B, A, C) или даже (C, A, B) , пока гарантируется правильность программы и гарантируется семантика нашего кода верхнего уровня, мы позволяем компилятору и процессору использовать свои собственные средства для оптимизации. С точки зрения видимости результат A виден B, а результат B виден C.

Многоуровневая конструкция кэша

Современные архитектуры процессоров представляют собой многоуровневые кэши.ЦП имеет кэши L1, L2, L3 и, наконец, DRAM.Для кода, сгенерированного компилятором, сначала используются регистры, а затем основная память. Следовательно, в параллельном сценарии должна возникнуть проблема согласованности, и изменение переменных одним исполнительным органом может быть не сразу видно другим исполнительным органам.

Оптимизация компиляции и внеочередное выполнение процессора

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

Что происходит-до?

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

Как понять Бывает-прежде? Если его прямо перевести как "происходит раньше", то это далеко пойдет. "происходит-прежде" не означает, что предыдущая операция происходит до последующей операции с точки зрения физического времени. На самом деле оно хочет выразить следующее: предыдущее действиерезультат операциидля последующих операцийвидимый(Можно понять, что логический порядок операций последовательный). Happens-before по сути является отношением частичного порядка, поэтому оно должно удовлетворять транзитивности. Мы говорим, что А происходит до В, то есть результат А виден В, сокращенно А

Почему у Бывает-раньше правила?

  • Во-первых, если язык Go предоставляет разработчикам Go барьерные операции для принятия решения о том, когда вставлять барьеры, то параллельный код сложно писать и он подвержен ошибкам (технически выполнимо, главным образом потому, что он слишком низкоуровневый, как это делает c). ;
  • Кроме того, для компилятора и процессора эти барьеры памяти и барьеры оптимизации являются ограничениями, так что нижний уровень не может оптимизировать по своему желанию, поэтому этих ограничений должно быть как можно меньше;

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

Выбор Go состоит в том, чтобы предоставить программистам ограниченное количество правил, подобных аксиомам, которые являются нашими правилами «происходит до» (аналогично другим языкам высокого уровня, таким как java). Когда программисты Go разрабатывают, только следуя этим правилам, они могут обеспечить правильную семантику и правильную видимость. Вещи, выходящие за рамки этих правил, не являются частью обещаний Go, и естественные последствия неизвестны.

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

Видимость памяти в C

Видимость памяти — общая проблема, похожая на c/c++, golang и java, есть соответствующие стратегии. Для сравнения, давайте сначала подумаем о языке с, но в нем почти нет теоретических правил для случая-до того, как это происходит. Причина в том, что язык с слишком низкоуровневый, а видимость в памяти общего языка с обычно гарантируется двумя относительно примитивные средства:

  • volatileключевое слово (это ключевое слово есть во многих языках, но смысл совсем другой, здесь обсуждается только c)
  • memory barrier

volatileключевые слова

Переменные, объявленные volatile, не будут оптимизированы компилятором и могут быть получены из памяти при доступе, в противном случае очень вероятно, что чтение и запись переменных будет осуществляться только в регистры временно. Однако в сvolatileключевые слова не обеспечивают строгую связь «происходит до» и не предотвращают неупорядоченное выполнение инструкций процессора, иvolatileНе гарантируется и атомарная работа.

vo В качестве примера возьмем простой общий код на C:

// done 为全局变量
int done = 0;
while ( ! done ) {
    /* 循环内容 */
}

// done 的值,会在另一个线程里会修改成 1;

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

while ( 1 ) {
    /*  循环内容 */
}

Было подтверждено, что это бесконечный цикл во время компиляции, но на самом делеdoneЗначение будет изменено в других потоках, так что это не соответствует нашим ожиданиям, поэтому в этом сценарии переменнаяdoneэто использоватьvolatileЧтобы украсить, показывайте объявление каждый разdoneЗначение должно попасть прямо в память.

memory barrier

Барьеры памяти, также известные как барьеры памяти, делятся на две категории:

  1. Барьеры компилятора (он же барьеры оптимизации) — для генерации кода во время компиляции
  2. барьеры памяти процессора - работают против инструкций во время выполнения

Оба типа барьеров могут быть вручную вставлены в c, например:

// 编译器屏障,只针对编译器生效(GCC 编译器的话,可以使用 __sync_synchronize)
#define barrier() __asm__ __volatile__("":::"memory")

// cpu 内存屏障
#define lfence() __asm__ __volatile__("lfence": : :"memory") 
#define sfence() __asm__ __volatile__("sfence": : :"memory") 
#define mfence() __asm__ __volatile__("mfence": : :"memory") 

Барьеры оптимизации предотвращают генерацию кода не по порядку, а барьеры памяти ЦП предотвращают выполнение инструкций не по порядку. Многие удивятся, что когда я пишу код на C, кажется, что я никогда вручную не вставляю барьеры памяти? На самом деле, операция блокировки библиотеки c (например,pthread_mutx_t) является естественным барьером.

Описание: Способ, которым язык C гарантирует видимость памяти, очень прост и примитивен, почти с учетом операции на уровне инструкций.

Голанг происходит раньше

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

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

происходит-прежде — это семантическое обещание, данное вам на уровне языка Golang, например:A happen-before B, результат выполнения A виден B. Но обратите внимание, что компилятору и процессору разрешено выполнять любую оптимизацию при условии обеспечения семантики, то есть из линии реального времени это не значит, что A выполняется раньше B, и программист не обращает внимания на На это (а на это нет возможности обратить внимание) программистам нужно обращать внимание только на семантику происшедшего — до того, как код не изменился.

Golang обеспечивает обещания правила «происходит до того, как» в 5 областях.

Обратите внимание, что позже мы говорим, что А происходит до В, что эквивалентно А

Давайте расширим и посмотрим, что происходит перед правилами, которые предоставляет Golang.Они классифицируются в соответствии со сценариями и типами.Конкретные правила заключаются в следующем:

Initialization

Официальное описание:

If a package p imports package q, the completion of q's init functions happens before the start of any of p's.

объяснение правила:

import packageКогда, если пакет p выполняется внутриimport q, то функция инициализации пакета q выполняется перед любым другим кодом, выполняемым после пакета p в логическом порядке.

Например:

// package p
import "q"      // 1
import "x"      // 2

Когда (2) выполняется, результат выполнения функции инициализации пакета q виден (2), другими словами, q'sinitФункция выполняется первой,import "x"после казни.

Goroutine creation

Официальное описание:

The go statement that starts a new goroutine happens before the goroutine's execution begins.

объяснение правила:

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

Например:

var a int

func main() {
	a = 1          // 1
	go func() {   // 2
		println(a)  // 3
	}()
}

В соответствии с этим происходит-до правила:

  1. Результат (2) можно увидеть в (3), то есть 2
  2. (1) естественно предшествует (2), где 1

происходит-перед принадлежит правилу частичного порядка и является транзитивным, поэтому 1println(a), он должен быть выполненa=1инструкции. Другими словами, основная горутина изменяет значение a, и результат находится в сопрограмме.printlnвидно.

Goroutine destruction

Официальное описание:

The exit of a goroutine is not guaranteed to happen before any event in the program.

объяснение правила:

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

Например:

var a string

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

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

Channel communiaction

Официальное описание:

  1. A send on a channel happens before the corresponding receive from that channel completes.
  2. The closing of a channel happens before a receive that returns a zero value because the channel is closed.
  3. A receive from an unbuffered channel happens before the send on that channel completes.
  4. The kth receive on a channel with capacity C happens before the k+Cth send from that channel completes.

объяснение правила:

Всего в канале задействовано 4 случая — до правил, наибольшее количество. Из этого числа мы также можем видеть, что канал в Golang является первоклассным выбором для обеспечения видимости памяти, упорядоченности и синхронизации кода, Давайте проанализируем их один за другим:

Правило первое объясняет:

A send on a channel happens before the corresponding receive from that channel completes.

Элемент записи (отправки) канала можно увидеть, когда соответствующая операция чтения (получения) элемента завершена. Обратите внимание на ключевое слово: "Соответствующие элементы, относящиеся к одному и тому же элементу", говоря об одном и том же элементе. Другими словами, операция отправки элемента завершается (возвращается) до вызова приема элемента.

Например:

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

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

func main() {
	go f()              // C
	<-c                 // D
	print(a)            // E
}

Этот пример гарантирует, что основная сопрограмма выведет строку «hello, world», т.е.a="hello, world"Задание можно найти вprint(a)это утверждение. Мы исходим из правила «происходит до»:

  1. C
  2. A
  3. B <-c), что и является именно этим правилом;
  4. D

Таким образом, порядок выполнения всей логической видимости таков: C print(a)когда это должно бытьaПосле того, как он был назначен.

Правило 2 объясняет:

The closing of a channel happens before a receive that returns a zero value because the channel is closed.

Закрытие канала (еще не завершенное) поведение можно увидеть в возврате приема канала (возвращает 0, потому что канал закрыт);

Например:

Следующий пример очень похож на приведенный выше, просто изменитеc<- 0заменитьclose(c) , толчок к процессу точно такой же.

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

func f() {
	a = "hello, world"  // A
    close(c)            // B
}

func main() {
	go f()              // C
	<-c                 // D
	print(a)            // E
}

Порядок, в котором выполняется вся логическая видимость, следующий: C print(a)когда это должно бытьaПосле того, как присвоение выполнено, «hello, world» также может быть напечатано правильно.

Правило 3 разъяснено:

A receive from an unbuffered channel happens before the send on that channel completes.

Третье правило дляno bufferОперацию приема канала без буфера и канала без буфера можно увидеть, когда операция элемента отправки завершена.

Например:

var c = make(chan int)
var a string

func f() {
    a = "hello, world"      // A
    <-c                     // B
}

func main() {
    go f()                  // C
    c <- 0                  // D
    print(a)                // E
}
  1. C
  2. A
  3. B c<-0), другими словами, операция B была выполнена, когда выполняется D, что и является именно этим правилом;
  4. D

Итак, порядок выполнения всей видимости таков: C print(a)когда это должно бытьaПосле того, как присвоение выполнено, «hello, world» также может быть напечатано правильно.

Чтобы гарантировать, что это произойдет, правило golang перед выполнением, даже если сначала выполняется физическое время.c<-0Эта строка заставит ждать, пока D увидит выполнение B, и это правило будет соблюдено.c <- 0Вернитесь, чтобы можно было добиться правильной видимости.

Обратите внимание, что это правило применяется только к каналам без буферов, если приведенный выше пример заменить каналами с буферами.var c = make(chan int, 1), то программа не гарантируетprint(a)напечатать «привет, мир».

Правило 4 объясняет:

The kth receive on a channel with capacity C happens before the k+Cth send from that channel completes.

Четвертое правило является общим правилом, которое означает, что если длина кольцевого буфера канала равна C, то операция приема K-го элемента завершается до операции отправки K+C-го элемента;

Если хорошенько подумать об этом правиле, то, когда C равно 0, на самом деле это правило 3, а это означает, что канал без буфера является лишь частным случаем этого правила.

Например:

Основываясь на приведенных выше правилах, давайте возьмем простой пример изображения:

c := make(chan int, 3)
c <- 1 
c <- 2
c <- 3
c <- 4  // A
go func (){
    <-c     // B     
}()

Результат операции B можно увидеть в A (или, по логике, B выполняется раньше A), что, если строка A прибудет первой по времени? Он будет ждать и ждать завершения выполнения B. После того, как B вернется, A проснется, и операция отправки будет завершена. Только таким образом семантика этого правила «до того, как произойдет» останется неизменной, гарантируя, что B будет выполнено раньше, чем A. Golang гарантирует это в реализации chan.

Вообще говоря, для этого типа буферного канала только один человек может сидеть в яме, и элемент K + C должен ждать, пока элемент K займет позицию.Внутренность chan представляет собой кольцевой буфер, который сам по себе является кольцом, поэтому элемент K+th Элемент C и элемент K должны указывать на одно и то же место, которое должно быть [The kth receive ] happens-before [ the k+Cth send from that channel completes. ]

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

  • Текущие элементы в кольцевом буфере эквивалентны текущему потреблению ресурсов;
  • Размер кольцевого буфера канала эквивалентен максимальному количеству ресурсов;
  • отправить элемент эквивалентно получению семафора;
  • получить эквивалентно освобождению семафора (освобождению ямы);
  • Из-за обещанного Голангом правила «происходит до того, как» указано поведение синхронизации элемента K и K + Cth (одна и та же яма);

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

var limit = make(chan int, 3)

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

Разве это не использование, которое ограничивает 3 параллелизма.

Locks

Официальное описание:

  1. For any sync.Mutex or sync.RWMutex variable l and n < m, call n of l.Unlock() happens before call m of l.Lock() returns.
  2. For any call to l.RLock on a sync.RWMutex variable l, there is an n such that the l.RLock happens (returns) after call n to l.Unlock and the matching l.RUnlock happens before call n+1 to l.Lock.

объяснение правила:

Есть два правила «происходит до» относительно блокировок.

Правило первое объясняет:

For any sync.Mutex or sync.RWMutex variable l and n < m, call n of l.Unlock() happens before call m of l.Lock() returns.

Это правило распространяется на любойsync.Mutexа такжеsync.RWMutexБлокирующая переменная L n-го вызоваL.Unlock()Логика предшествует (результат виден) m-му вызовуL.Lock()работать.

Например:

var l sync.Mutex
var a string

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

func main() {
    l.Lock()        // A 
    go f()          // B
    l.Lock()        // C
    print(a)        // D
}

Этот пример выводит:

  1. A
  2. B
  3. E <= F
  4. F
  5. C <= D

Таким образом, общая логическая цепочка последовательностей: A print(a)Когда должно быть назначено «привет, мир», программа может оправдать ожидания.

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

Правило 2 объясняет:

For any call to l.RLock on a sync.RWMutex variable l, there is an n such that the l.RLock happens (returns) after call n to l.Unlock and the matching l.RUnlock happens before call n+1 to l.Lock.

Это второе правило для sync.RWMutexПеременная блокировки типа L, которая говоритL.Unlock( )можно увидеть вL.Rlock( ), энныйL.Runlock( )перед n+1-мL.Lock().

Я бы сказал по-другому, два аспекта:

  1. L.Unlockразбудит другие ожидающие блокировки чтения (L.Rlock( ))просить;
  2. L.RUnlockразбудит другихL.Lock( )просить

Once

Официальное описание:

A single call of f() from once.Do(f) happens (returns) before any call of once.Do(f) returns.

объяснение правила:

Что говорит правилоf( )функция выполняется раньшеonce.Do(f)возвращение. другими словами,f( )должны быть выполнены в первую очередь.once.Do(f)вызов функции для возврата.

Например:

var a string
var once sync.Once

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

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

func twoprint() {
    go doprint()        // D
    go doprint()        // E
}

Этот пример гарантируетsetup( )должен быть выполнен в первую очередьonce.Do(setup)Звонок не вернется, так что подождите, покаprint(a)Когда , должно быть присвоено значение. Таким образом, приведенная выше программа напечатает «hello, world» дважды.

неправильный пример

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

var a string
var done bool

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

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

В этом примере не гарантируется печать "hello, world",print(a)При выполнении a не может быть присвоено значение, и нет правила, гарантирующего порядок A

  • a="hello, world"а такжеdone=trueНет гарантии фактического порядка выполнения этих двух строк кода, ЦП может выполняться не по порядку;
  • Что еще хуже, основная программа может зацикливаться бесконечно, потому чтоdoneНазначение находится в другой параллельной горутине и не гарантируется, что она будет видна основной функцией;

Между goroutine: setup и goroutine: main не существует правила обещания, которое не может гарантировать видимость A

Как это исправить?

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

A receive from an unbuffered channel happens before the send on that channel completes.

var a string
var done bool
var c = make(chan int)

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

	<-c // C
}

func main() {
	go setup()

	c <- 0 // D

	for !done {
	}
	print(a) // B
}

Две горутины, горутина, в которой находится настройка, и горутина, в которой находится основная, не имеют гарантий видимости, поэтому возникает эта проблема. Нам нужно только добавить гарантию видимости в соответствующую позицию. Как в приведенном выше примере. , нам нужно только добавить C и D. Точка карты, это правило является правилом «происходит до того, как» явно зафиксировано golang (получение происходит до того, как операция отправки без буферного канала завершена), вывод:

  1. С
  2. Так как A

В соответствии с транзитивностью выводится A print(a)Когда a уже назначен.

Суммировать

  1. Видимость памяти — частая проблема для программ, на каком бы языке она ни была, она будет считаться и обрабатываться, разница только в форме обработки;
  2. Обработка c относительно примитивна, в ней используется ключевое слово volatile для запрета оптимизации компиляции и барьеры памяти для запрета оптимизации компиляции и оптимизации выполняемых инструкций;
  3. Случается-прежде - это вещь более высокого уровня, причинно-следственная связь (отношение логической последовательности результатов), явно гарантированная языком программирования.Многие языки высокого уровня имеют эту главу, golang, java и т. д. аналогичны;
  4. происходит-прежде относится не к временному порядку выполнения, а кВидимость результатов, этот момент очень важен;
  5. golang предоставляет правила «случается-до» об инициализации пакета, создании горутины, уничтожении горутины, канале связи, блокировках, один раз, среди которых канал и блокировки являются наиболее задействованными, поэтому каналы и блокировки также являются наиболее распространенными для обеспечения правильных средств видимости памяти;
  6. Программистам Golang нужно только сравнить правила «происходит до», обещанные языком для реализации правильных параллельных программ для обеспечения правильной видимости, в то время как компиляторы и процессоры ЦП могут свободно играть в рамках ограничений этого ограниченного правила «происходит до» и оптимизировать столько, сколько им нравится. логика. Таким образом, они получают лучший баланс;
  7. Большинство примеров кода в этой статье взяты из официальных примеров, очень классических и применимых для понимания;

Больше галантереи здесь, общедоступный аккаунт: Qiya Cloud Storage