Горутины работают с одной памятью, но это не значит, что запись одной сразу видна другой. Компилятор и процессор могут менять порядок операций и кэшировать значения. Модель памяти 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.
Канал связывает записи отправителя с чтением получателя: после приема сигнала 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.
- Мьютекс нужен и на запись, и на чтение.
- Атомик работает для отдельного значения, не для инварианта нескольких полей.
- Детектор гонок не заменяет проверку логики.