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

Проблема обычного флага

var ready bool

go func() {
    for !ready {
    }
    fmt.Println("started")
}()

time.Sleep(time.Millisecond)
ready = true

Цикл может завершиться, а может продолжаться бесконечно. Слово ready = true не создаёт связи между горутинами, поэтому чтение не обязано увидеть новое значение. Sleep тоже не синхронизирует память: он лишь увеличивает шанс планировщику дать записать.

Это data race: одна горутина пишет, другая читает без синхронизации. Решение зависит от смысла флага:

var ready atomic.Bool

go func() {
    for !ready.Load() {
        runtime.Gosched()
    }
    fmt.Println("started")
}()

ready.Store(true)

atomic.Bool подходит для одиночного флага. Для сообщения лучше канал: отправка сообщения сама сообщает и данные, и порядок.

Отношение happens-before

A happens-before B означает: всё, что произошло до A в одной горутине, будет видно после B в другой. Это не настенные часы, а гарантия порядка и видимости.

Практические пары:

  • отправка в канал завершается до соответствующего получения;
  • закрытие канала происходит до получения нулевого значения после закрытия;
  • Unlock идёт до следующего Lock того же мьютекса;
  • wg.Done идёт до возвращения wg.Wait;
  • запуск горутины идёт после операции go, но её завершение после неё не гарантируется.
ready := make(chan struct{})
go func() {
    close(ready)
}()

<-ready

Закрытие канала здесь не только сигнал. Оно связывает предыдущие действия горутины с получателем. После <-ready можно читать записи, сделанные до close.

A: x = 1 A: close(ready) B: <-ready B: читает x = 1

Канал связывает записи отправителя с чтением получателя: после приема сигнала B видит значение, записанное до close.

Мьютекс работает как барьер

Мьютекс не просто запрещает одновременный вход. Он создаёт порядок между предыдущим Unlock и следующим Lock.

type State struct {
    mu    sync.Mutex
    value int
}

func (s *State) Inc() {
    s.mu.Lock()
    s.value++
    s.mu.Unlock()
}

func (s *State) Value() int {
    s.mu.Lock()
    defer s.mu.Unlock()
    return s.value
}

Поэтому Value тоже берёт мьютекс. Если чтение оставить незащищённым, переменную можно увидеть посередине обновления или с устаревшим значением.

RWMutex разрешает параллельные чтения, но запись требует Lock. Чтение через RLock должно завершиться до записи, поэтому нельзя менять данные внутри RLock.

Атомики и их границы

atomic.Int64.Add(1) делает операцию неделимой. Но атомарность одной переменной не создаёт транзакцию над несколькими:

balance.Add(-price)
sold.Add(1)

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

Атомики подходят для счётчиков, флагов и последовательностей. Для составного состояния - нет.

Типовые ошибки

  • флаг done bool вместо atomic.Bool или канала;
  • чтение среза без блокировки, пока другая горутина делает append;
  • две переменные, которые меняют под разными мьютексами, хотя инвариант общий;
  • wg.Wait ожидается одной горутиной, а результаты читаются другой без синхронизации;
  • копирование структуры с мьютексом после первого использования.

Детектор -race находит гонку, но не отсутствие логической атомарности. Для второго нужны тесты на инвариант и аккуратные границы данных.

Коротко

  • Видимость появляется только через синхронизацию, не через время ожидания.
  • Канал передаёт и значение, и порядок happens-before.
  • Мьютекс нужен и на запись, и на чтение.
  • Атомик работает для отдельного значения, не для инварианта нескольких полей.
  • Детектор гонок не заменяет проверку логики.