golang

Деконструкция Go: модель памяти, happens-before и почему ваш код работает. Часть 0

  • четверг, 16 апреля 2026 г. в 00:00:12
https://habr.com/ru/articles/1023762/

Приветствую всех!

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

Решил я фундаментально разобрать то, как работает Golang, потому что в интернете(YT, Конфы и пр.), на мой взгляд, крайне много откровенно поверхностной и верхнеуровневой информации. Я, конечно, буду рад, если вы укорите меня в моих слабых навыках поиска и покажете мне, что реальность не такая, какой я её выдумал, но субъективно это так.

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

Моя главная цель – разобрать всё максимально исчерпывающе, насколько я это смогу.

Чтож, поехали!

Ах, да. В этом цикле не будет особо веселых рисуночков с гоферами, а скучные блок-схемы, диаграммы и вырезки из кода.

Структурная схема

Структурная схема модели памяти
Структурная схема модели памяти

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

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

Аппаратный слой

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

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

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

Сам CPU определяет:

  • Порядок выполнения инструкций

  • Допустимые переупорядочивания(reorder) операций чтения/записи

  • Работу процессорных кэшей

  • Атомарность инструкций

  • Барьеры памяти

Проще говоря, конечная инстанция Go – это ассемблерные инструкции процессору.

Конкретно в Go это реализовано например вот здесь(взял в качестве примера атомики)

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

Низкоуровневые аппаратные инструкции

Небольшое напоминание

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

То есть рассматривается потоком(-ми) как один мгновенный шаг

x += 1 =>

load x
x+1
store x ; (3 шага)

atomic.AddUint32(&x, 1) => выглядит для других потоков как один шаг

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

В этих пакетах реализованы атомарные операции через комбинацию:

  • Архитектурно-зависимого Go-кода

  • Вставок ассемблера

  • Аппаратных атомарных инструкций процессора

Данный слой связывает модель памяти Go с реальными возможностями аппаратной архитектуры.

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

Runtime

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

  • Управление горутинами

  • Реализация планировщика

  • Управление стеком

  • Реализация каналов

  • Запуск GC

  • Обеспечение системных вызовов

Исходники данного творчества здесь

На самом деле, про этот слой может быть много сказано(и будет) но пока мы лишь обозреваем

Synchronization events

Пакет sync знаете? Вот во многом про него


Но для начала вводная

На уровне спецификации языка у нас существует sequenced-before. За этим умным словом спрятан порядок выполнения операций в рамках одной горутины. Но вот вопрос – а что если горутин несколько? Вот тут и появляются наши события синхронизации.

В модели памяти Go вводится 2 типа операций:

Обычные в духе

read

write

И синхронизирующие. Например 

channel send

channel receive

channel close

mutex lock

mutex unlock

atomic operations

goroutine creation

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

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

То есть если вторая операция синхронизации наблюдает результат первой, то первая synchronized before второй

Давайте рассмотрим пример на Go, чтобы это осознать.

var x int
ch := make(chan int)

go func() {
  ch <- 1
}()

<-ch
fmt.Println(x)

Тогда получим

send synchronized-before receive

Соответственно, synchronized-before – это и есть мост между общением горутин

Официальная модель памяти Go

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

Итак, в официальной модели памяти Go определены 3 соотношения

sequenced-before – порядок выполнения внутри одной goroutine

synchronized-before – порядок синхронизации

happens-before – ключевая концепция

happens-before определяется как…

транзитивное замыкание двух предыдущих[sequenced-before и synchronized-before] отношений

Или же

happens-before = closure(
  sequenced-before ∪ synchronized-before
)

Про то, что есть транзитивное замыкание, можно почитать на вики, а я пока постараюсь объяснить суть

Пусть есть 2 операции в разных горутинах G1 и G2

G1:
x = 1
ch <- 1

G2:
<-ch
fmt.Println(x)

Тогда получим

x=1 sequenced-before send(ch)
send(ch) synchronized-before receive(ch)
receive(ch) sequenced-before print(x)

Из определения happens-before получаем

x = 1 happens-before print(x)

И теперь перейдем к главному правилу

Пусть операция чтения r читает значение переменной x.

Тогда запись w может быть наблюдаемой для r, если выполняются два условия:

1. w happens-before r

2. не существует другой записи w2,

такой что w happens-before w2 happens-before r

Если по-простому в моей формулировке

Мы можем получить доступ к результату записи когда:
1. Запись происходит перед чтением

2. Не существует записи под номером 2, которая переписывает нужную нам запись

Соответственно, получаем простое свойство:

Если программа не содержит гонок данных, она ведёт себя так, как будто все операции выполняются в одном глобальном порядке

Слой гарантии для прикладных программ

Вообще, что за слой гарантии? Ранее, я упомянул

Если программа не содержит гонок данных, она ведёт себя так, как будто все операции выполняются в одном глобальном порядке

Или же

Programs that are data-race-free behave as if all goroutines were executed sequentially on a single processor

Это является главной гарантией языка

Она так же называется DRF-SC (data-race-free => sequentially consistent)

Вообще давайте разберем, что есть явление data race, но не “на пальцах”, а формально

Data Race(гонка за данные) – это ситуация конфликта доступа к данным, возникающая при выполнении одновременно трех условий:

1. две goroutine обращаются к одной переменной

2. хотя бы одна операция — запись

3. между ними нет соотношения happens-before

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

На основе этого, можно сформулировать простое и очевидное правило –

не допускайте гонки данных

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

Буду рад любым комментами, фидбэку etc. всем спасибо)

Только зарегистрированные пользователи могут участвовать в опросе. Войдите, пожалуйста.
Как вам статья?
33.33%Понравилась1
0%Приемлемо0
33.33%Нейтрально1
33.33%Плохо1
0%Ужасно0
Проголосовали 3 пользователя. Воздержавшихся нет.