Модель памяти Go (Go Memory Model) определяет условия, при которых чтение переменной в одной горутине гарантированно видит значение, записанное другой горутиной. Без понимания модели памяти невозможно писать корректный конкурентный код.
Зачем нужна модель памяти?
Современные процессоры и компиляторы переупорядочивают инструкции для оптимизации. То, что вы написали:
x = 1
y = 2
Может выполниться в любом порядке, если нет зависимости по данным. В однопоточном коде это незаметно -- результат тот же. Но в многопоточном коде другая горутина может увидеть y = 2 до x = 1.
// INCORRECT: data race, no synchronization
var x, y int
// Goroutine 1
go func() {
x = 1
y = 2
}()
// Goroutine 2
go func() {
if y == 2 {
fmt.Println(x) // May print 0! Not guaranteed to see x = 1
}
}()
Модель памяти определяет, какие гарантии видимости даёт каждый примитив синхронизации.
Отношение Happens-Before
Happens-before -- это частичный порядок на операциях программы. Если операция A "happens before" операции B (обозначается A < B), то B гарантированно видит результаты A.
Без happens-before между записью и чтением -- результат чтения не определён (data race).
Основные правила happens-before
- Внутри одной горутины: каждая операция happens-before следующей (в program order)
- Между горутинами: happens-before устанавливается только через синхронизацию
Гарантии init()
// Package initialization happens-before main()
// init() in package A happens-before init() in package B
// if B imports A
// a/a.go
package a
var X int
func init() {
X = 42 // Happens-before any code that imports package a
}
// main.go
package main
import "a"
func main() {
fmt.Println(a.X) // Guaranteed to see 42
}
Порядок:
- Все
init()пакета A выполняются доinit()пакета B (если B импортирует A) - Все
init()выполняются в одной горутине - Все
init()завершаются доmain()
Гарантии горутин
Создание горутины
Оператор go happens-before начала выполнения горутины:
var msg string
func main() {
msg = "hello" // (1)
go func() { // (2) go statement happens-before goroutine start
fmt.Println(msg) // (3) guaranteed to see "hello"
}()
}
// (1) < (2) < (3): msg = "hello" is visible
Завершение горутины
Завершение горутины НЕ happens-before чему-либо автоматически. Нужна явная синхронизация:
var msg string
func main() {
go func() {
msg = "hello" // No happens-before relationship with main
}()
fmt.Println(msg) // May print "" -- data race!
}
Гарантии каналов (Channels)
Каналы -- основной механизм синхронизации в Go.
Правило 1: Отправка happens-before получение
var msg string
ch := make(chan struct{})
go func() {
msg = "hello" // (1)
ch <- struct{}{} // (2) send happens-before receive
}()
<-ch // (3) receive happens-after send
fmt.Println(msg) // (4) guaranteed "hello"
// (1) < (2) < (3) < (4)
Правило 2: Закрытие канала happens-before получение нулевого значения
var msg string
ch := make(chan struct{})
go func() {
msg = "hello"
close(ch) // Close happens-before receive of zero value
}()
<-ch // Receives zero value after close
fmt.Println(msg) // Guaranteed "hello"
Правило 3: Небуферизированный канал -- получение happens-before завершение отправки
Для unbuffered каналов получение завершается до возврата из отправки:
var msg string
ch := make(chan struct{}) // Unbuffered!
go func() {
msg = "hello"
<-ch // (1) receive completes
}()
ch <- struct{}{} // (2) send returns AFTER receive completes
fmt.Println(msg) // Guaranteed "hello"
// (1) happens-before (2) for unbuffered channels
Правило 4: Буферизированный канал с ёмкостью C
k-ая операция получения happens-before (k+C)-ая операция отправки:
// Buffered channel as semaphore
sem := make(chan struct{}, 3) // capacity 3
for i := range 10 {
sem <- struct{}{} // Blocks when 3 goroutines are running
go func() {
defer func() { <-sem }()
doWork(i)
}()
}
Гарантии Mutex
Lock/Unlock ordering
Для sync.Mutex (и sync.RWMutex): n-ый вызов Unlock() happens-before (n+1)-ый вызов Lock():
var mu sync.Mutex
var data int
// Goroutine 1
go func() {
mu.Lock()
data = 42 // Protected write
mu.Unlock() // (1) Unlock happens-before...
}()
// Goroutine 2
go func() {
mu.Lock() // (2) ...next Lock
fmt.Println(data) // Guaranteed to see 42 (if Lock acquires after Unlock)
mu.Unlock()
}()
RWMutex дополнительно
RUnlock()happens-before следующийLock()Unlock()happens-before следующийRLock()
Гарантии sync.Once
once.Do(f) -- вызов f happens-before возврат из любого once.Do:
var once sync.Once
var config *Config
func getConfig() *Config {
once.Do(func() {
config = loadConfig() // (1) Executed exactly once
})
// (2) Every call to getConfig() sees the result of (1)
// Even from other goroutines!
return config
}
Это сильная гарантия: не только первый вызов, но все вызовы once.Do видят результаты функции. Даже если горутина B вызывает once.Do позже, она гарантированно видит config.
Гарантии sync.WaitGroup
var wg sync.WaitGroup
results := make([]int, 10)
for i := range 10 {
wg.Add(1)
go func() {
defer wg.Done() // Done happens-before Wait returns
results[i] = compute(i)
}()
}
wg.Wait() // Blocks until all Done() calls
// All results[i] are visible here
fmt.Println(results)
wg.Done() (which is wg.Add(-1)) happens-before wg.Wait() returns.
Гарантии атомарных операций
Пакет sync/atomic предоставляет атомарные операции с гарантиями happens-before:
import "sync/atomic"
var flag atomic.Bool
var data int
// Goroutine 1
go func() {
data = 42 // (1)
flag.Store(true) // (2) atomic store
}()
// Goroutine 2
go func() {
for !flag.Load() { // (3) atomic load
runtime.Gosched()
}
fmt.Println(data) // (4) guaranteed 42
// (1) < (2), and (2) is synchronized-with (3), so (1) < (4)
}()
Атомарные типы (Go 1.19+)
var counter atomic.Int64
var pointer atomic.Pointer[Config]
counter.Add(1)
counter.Store(0)
val := counter.Load()
cfg := &Config{Host: "localhost"}
pointer.Store(cfg)
current := pointer.Load()
Важно: Атомарные операции гарантируют happens-before для всех данных, не только для атомарной переменной.
atomic.Storeв горутине 1 "синхронизируется с"atomic.Loadв горутине 2.
Переупорядочивание компилятором и процессором
Компиляторное переупорядочивание
// Compiler may reorder these (no data dependency):
x = 1
y = 2
// Could become: y = 2; x = 1
// Compiler will NOT reorder these (data dependency):
x = 1
y = x + 1
// x = 1 must happen before y = x + 1
Процессорное переупорядочивание
Разные архитектуры имеют разные модели:
- x86/x64 (TSO): строгая модель, store-store не переупорядочиваются
- ARM/ARM64: слабая модель, почти всё может переупорядочиваться
- RISC-V: слабая модель
Go абстрагирует от архитектуры -- примитивы синхронизации вставляют нужные memory barriers.
Пример: Store Buffering
// On ARM, without synchronization:
var x, y int32
// Goroutine 1
go func() {
atomic.StoreInt32(&x, 1) // Includes memory fence on ARM
r1 := atomic.LoadInt32(&y)
}()
// Goroutine 2
go func() {
atomic.StoreInt32(&y, 1) // Includes memory fence on ARM
r2 := atomic.LoadInt32(&x)
}()
// Without atomics, r1 == 0 && r2 == 0 is possible on ARM!
// With atomics, memory fences prevent this
False Sharing и выравнивание по cache line
False sharing происходит, когда разные горутины модифицируют переменные, находящиеся на одной cache line (обычно 64 байта):
// BAD: false sharing -- counters on same cache line
type BadCounters struct {
A atomic.Int64 // These are adjacent in memory
B atomic.Int64 // Same cache line!
}
// GOOD: padding to separate cache lines
type GoodCounters struct {
A atomic.Int64
_ [56]byte // Padding to fill cache line (64 - 8 = 56)
B atomic.Int64
}
func BenchmarkBadCounters(b *testing.B) {
var c BadCounters
b.RunParallel(func(pb *testing.PB) {
for pb.Next() {
c.A.Add(1) // Contention with B!
}
})
}
func BenchmarkGoodCounters(b *testing.B) {
var c GoodCounters
b.RunParallel(func(pb *testing.PB) {
for pb.Next() {
c.A.Add(1) // No contention with B
}
})
}
// GoodCounters can be 2-10x faster under contention!
Использование runtime.CacheLinePad (если доступен)
type PaddedCounter struct {
value atomic.Int64
_ [7]int64 // Ensure next field is on a different cache line
}
Data Race vs Race Condition
- Data Race: две горутины обращаются к одной переменной одновременно, хотя бы одна пишет, без синхронизации. Всегда баг. Go race detector находит это.
- Race Condition: логическая ошибка из-за неожиданного порядка операций. Может быть даже с правильной синхронизацией.
// Data Race (detectable by -race):
var counter int
go func() { counter++ }()
go func() { counter++ }()
// Race Condition (NOT detectable by -race):
var mu sync.Mutex
var balance int
// Transfer money: check-then-act race condition
func withdraw(amount int) bool {
mu.Lock()
if balance >= amount {
mu.Unlock()
// Another goroutine could withdraw here!
mu.Lock()
balance -= amount
mu.Unlock()
return true
}
mu.Unlock()
return false
}
// Correct: hold lock for entire operation
func withdrawCorrect(amount int) bool {
mu.Lock()
defer mu.Unlock()
if balance >= amount {
balance -= amount
return true
}
return false
}
Практические правила
- Если сомневаетесь -- используйте каналы или мьютексы
- Всегда запускайте тесты с
-raceв CI - Не полагайтесь на порядок без синхронизации
- Один факт: "программа без data races не обязательно корректна"
- Atomic -- для простых флагов/счётчиков, каналы/мьютексы -- для сложной логики