Escape Analysis и Go Memory Model — стек/куча и happens-before
Что это: escape analysis — оптимизация компилятора, решающая, где живёт объект (stack vs heap). Go Memory Model — формальные гарантии порядка операций в многопоточной программе. Зачем знать на Middle 1: на собесе спрашивают “почему этот объект на heap?” — нужно уметь читать вывод
-gcflags=-m. Memory Model — обязательная теория для конкурентного программирования; “будет ли race?” — типовой вопрос.
Содержание (TOC)
Заголовок раздела «Содержание (TOC)»- Базовая концепция
- Под капотом
- Тонкие моменты / Gotchas (12+)
- Производительность и compiler optimizations
- Когда использовать / когда НЕ использовать
- Вопросы на собесе Middle 1 (25)
- Practice — задачи (7)
- Источники
1. Базовая концепция
Заголовок раздела «1. Базовая концепция»1.1 Stack vs Heap
Заголовок раздела «1.1 Stack vs Heap»В Go (как и в большинстве языков) — две области памяти:
Stack (для каждой goroutine):
- Маленькая (стартует с 8 KB, может расти до 1 GB).
- LIFO — пушим/попим фреймы.
- Освобождается автоматически при выходе из функции.
- Быстрая (cache-friendly, нет GC).
Heap (общая для всех goroutine):
- Большая (ограничена RAM).
- Случайный доступ.
- Освобождается сборщиком мусора (GC).
- Медленнее (allocation + GC overhead).
Идеально — всё на стеке. Но не всегда возможно — иногда объект escapes (убегает) в heap.
1.2 Что такое escape analysis
Заголовок раздела «1.2 Что такое escape analysis»Компилятор Go (cmd/compile/internal/escape) анализирует код и решает: может ли объект жить на стеке или должен убежать на heap.
Объект убегает на heap, если:
- Возвращается указатель из функции.
- Захватывается в closure (escape via closure).
- Кладётся в interface.
- Сохраняется в глобальную переменную.
- Передаётся в channel.
- Слайс растёт (append с превышением cap).
- Размер неизвестен в compile-time.
Если ни одно из этих — объект остаётся на стеке.
1.3 Команда -gcflags=-m
Заголовок раздела «1.3 Команда -gcflags=-m»go build -gcflags="-m" main.gogo build -gcflags="-m -m" main.go # более подробноВыводит решения escape analysis:
./main.go:5:6: can inline foo./main.go:8:9: &User{} escapes to heap./main.go:8:9: moved to heap: u1.4 Go Memory Model — основа
Заголовок раздела «1.4 Go Memory Model — основа»Memory Model описывает: какие изменения, сделанные в одной goroutine, гарантированно видны в другой.
Ключевое понятие — happens-before (HB):
- Если событие A happens-before B, то изменения A видны в B.
- Без HB — нет гарантий (data race возможен).
HB устанавливается:
- Внутри одной goroutine (последовательное выполнение).
- Через channel: send HB receive.
- Через sync.Mutex: Unlock HB следующий Lock.
- Через sync.Once: Do() HB всё, что вызывается после.
- Через atomic: с Go 1.19+ — explicit ordering.
- Через init: package init HB main.
2. Под капотом
Заголовок раздела «2. Под капотом»2.1 Escape Analysis алгоритм
Заголовок раздела «2.1 Escape Analysis алгоритм»Компилятор:
- Строит dataflow граф программы.
- Помечает переменные как “escapes” если они “ускользают” в внешний скоуп.
- Маленькие объекты, не ускользающие — на стек.
Простой пример:
func noEscape() { x := 42 // local _ = x // not used outside} // x на стеке, освобождается на returnfunc escape() *int { x := 42 return &x // &x ускользает в return → x на heap}2.2 Подробный вывод
Заголовок раздела «2.2 Подробный вывод»$ go build -gcflags="-m -m" main.go./main.go:10:6: can inline foo with cost 4 as: func() *int { x := 42; return &x }./main.go:11:6: x escapes to heap:./main.go:11:6: flow: ~r0 = &x:./main.go:11:6: from &x (address-of) at ./main.go:12:9./main.go:11:6: from return &x (return) at ./main.go:12:2./main.go:11:6: moved to heap: xЗдесь компилятор объясняет: x escapes, потому что его адрес возвращается из функции.
2.3 Слайсы и escape
Заголовок раздела «2.3 Слайсы и escape»func slice1() { s := make([]int, 10) // на стеке (size known, mostly small) _ = s}
func slice2() { s := make([]int, 10000) // на heap (большой размер) _ = s}
func slice3(n int) { s := make([]int, n) // на heap (size unknown в compile-time) _ = s}
func slice4() []int { s := make([]int, 10) return s // escape (returned)}⚠️ Правило: компилятор может оставить slice на стеке только если:
- Размер известен в compile-time.
- Размер мал (<64KB примерно, точное число зависит от версии).
- Не “ускользает”.
2.4 Interface и escape
Заголовок раздела «2.4 Interface и escape»func foo() { x := 42 var y any = x // ← x escapes (boxing в interface) _ = y}Любое присваивание в interface{} для value types ведёт к heap (потому что interface хранит указатель).
Для pointer types — указатель уже есть, escape только если объект сам ускользает:
func bar() { x := &MyStruct{} var y any = x // x: уже на heap (потому что pointer escape into interface)}2.5 Closures
Заголовок раздела «2.5 Closures»func newCounter() func() int { count := 0 return func() int { // ← closure капчит count count++ return count }}count захвачен closure → escape to heap. Closure хранит указатель на heap-allocated count.
2.6 Inlining помогает
Заголовок раздела «2.6 Inlining помогает»func noEscape() { x := 42 print(&x) // print — небольшая, может быть inlined}Если print инлайнен — компилятор видит, что &x не уезжает, и оставляет x на стеке.
Лимит inlining: ~80 “стоимостных” единиц (точное число — cmd/compile/internal/inline/inl.go). Превышение — не инлайнится.
2.7 Go Memory Model: формальные гарантии
Заголовок раздела «2.7 Go Memory Model: формальные гарантии»Из официального документа https://go.dev/ref/mem:
Channel (буфер k, send i, receive i):
- The send of the k-th value on a channel of capacity C happens before the (k+C)-th receive completes.
- Closing a channel happens before a receive that returns because the channel is closed.
Mutex:
- The n-th call to m.Unlock() happens before the (n+1)-th call to m.Lock() returns.
Once:
- The function call f from once.Do(f) happens before any return from once.Do(f).
Atomic (Go 1.19+):
- atomic.Store(addr, x) happens before atomic.Load(addr) that returns x (for the same addr).
- This applies even between different goroutines.
2.8 Data Race определение
Заголовок раздела «2.8 Data Race определение»Из spec:
A data race occurs when two goroutines access the same variable concurrently and at least one of the accesses is a write.
Если есть data race — программа undefined behavior: компилятор может оптимизировать как угодно, результаты непредсказуемы.
2.9 -race detector
Заголовок раздела «2.9 -race detector»go run -race main.gogo test -race ./...Race detector:
- Использует shadow memory (по 2 байта shadow на каждый байт user memory).
- Для каждого доступа к памяти записывает: goroutine ID, vector clock.
- При конфликте (два доступа без HB) — выводит warning.
Overhead: 5-10x slowdown, 5-10x memory. Только для testing.
2.10 Channel close и memory model
Заголовок раздела «2.10 Channel close и memory model»ch := make(chan int)go func() { x := 42 // write close(ch)}()<-ch // ← после receive, гарантированно видим x = 42fmt.Println(x)close happens-before receive (включая receive of zero value). Поэтому запись x видна после receive.
2.11 Initialization happens-before
Заголовок раздела «2.11 Initialization happens-before»var globalX = computeX() // package init
func main() { // globalX гарантированно инициализирован use(globalX)}Package init happens-before main. Между разными package init — порядок определяется import graph.
3. Тонкие моменты / Gotchas
Заголовок раздела «3. Тонкие моменты / Gotchas»Gotcha 1: возврат указателя на локальную переменную — ОК
Заголовок раздела «Gotcha 1: возврат указателя на локальную переменную — ОК»Многих смущает после C/C++:
func newInt() *int { x := 42 return &x // OK! x перемещается на heap.}В C это UB (stack memory invalidated). В Go — escape analysis обнаруживает, перемещает на heap. Безопасно, но stoit чуть дороже (heap alloc).
Gotcha 2: slice escape unpredictable
Заголовок раздела «Gotcha 2: slice escape unpredictable»func F1() []int { s := make([]int, 10) return s // escape}
func F2(out *[]int) { *out = make([]int, 10) // escape (out может быть указатель на global)}
func F3() { s := make([]int, 10) // не escape (если не уходит дальше) _ = s}Слайсы — частая причина “почему мой код медленный”. Используй -gcflags=-m чтобы видеть.
Gotcha 3: append может перевыделить
Заголовок раздела «Gotcha 3: append может перевыделить»func appendStuff(s []int) []int { s = append(s, 1, 2, 3) // если cap < len + 3 → новая аллокация на heap return s}append либо использует существующий cap, либо аллоцирует новый bigger array (cap2 для маленьких, cap1.25 для больших). Новая аллокация — всегда heap.
Gotcha 4: fmt.Println форсирует escape
Заголовок раздела «Gotcha 4: fmt.Println форсирует escape»func foo() { x := 42 fmt.Println(x) // x escape (boxed в any)}Любая функция, принимающая any (как Println) — boxing. Объект ускользает в heap.
Gotcha 5: интерфейс параметр
Заголовок раздела «Gotcha 5: интерфейс параметр»type Reader interface { Read([]byte) (int, error) }
func Read(r Reader, buf []byte) { // r — интерфейс, объект за ним — на heap r.Read(buf)}Если r — это *os.File, объект os.File уже на heap (создан через os.Open). Если r — это struct value, она ушла в heap при boxing.
Gotcha 6: defer escape
Заголовок раздела «Gotcha 6: defer escape»func foo() { x := 42 defer fmt.Println(&x) // ← x escape (захвачен в defer closure)}defer создаёт closure, capturing variables. Captured variables — escape.
Но в Go 1.14+ оптимизация open-coded defer для простых случаев избегает closure → не escape.
Gotcha 7: goroutine escape
Заголовок раздела «Gotcha 7: goroutine escape»func foo() { x := 42 go func() { fmt.Println(x) // ← x escape (goroutine может жить дольше функции) }()}Goroutine — отдельный stack. Captured variables всегда escape to heap (нельзя ссылаться на stack чужой goroutine).
Gotcha 8: race на map — не всегда детектится
Заголовок раздела «Gotcha 8: race на map — не всегда детектится»m := map[string]int{}go func() { m["a"] = 1 }()go func() { _ = m["a"] }()Race detector ловит. Но даже без race detector — Go runtime fail-fast для concurrent map access:
fatal error: concurrent map read and map write(Это специальный assert внутри runtime/map.go).
Gotcha 9: atomic без правильного ordering
Заголовок раздела «Gotcha 9: atomic без правильного ordering»var ready int32var data int
go func() { data = 42 atomic.StoreInt32(&ready, 1)}()
for atomic.LoadInt32(&ready) == 0 {}fmt.Println(data) // 42 гарантировано? Да в Go 1.19+!В Go ≤ 1.18 — НЕ гарантировано (memory model был слабее). С Go 1.19+ — atomic.Store HB atomic.Load (sequential consistency для atomic).
Gotcha 10: range и captured variable
Заголовок раздела «Gotcha 10: range и captured variable»slice := []int{1, 2, 3}for _, v := range slice { go func() { fmt.Println(v) // ← в Go ≤ 1.21: capture by reference, печатает последний }()}В Go 1.22+ этот код работает корректно (каждая итерация — новая переменная). Но в старых версиях — race + неправильное поведение.
Gotcha 11: zero-sized struct и escape
Заголовок раздела «Gotcha 11: zero-sized struct и escape»type Empty struct{}
func foo() *Empty { e := Empty{} return &e // ← &e может указывать на специальный zerobase, не escape}Zero-sized structs — особый случай, runtime использует общий &zerobase для них. Adress может быть одинаковым для разных &Empty{}. Не escape в обычном смысле.
Gotcha 12: channel и memory model gotcha
Заголовок раздела «Gotcha 12: channel и memory model gotcha»ch := make(chan int, 1)go func() { x := 42 ch <- 1 fmt.Println(x) // запись после send — НЕ имеет HB-отношения с receiver}()<-chПосле <-ch НЕ гарантируется, что fmt.Println(x) в goroutine уже выполнен. Send HB receive, но НЕ наоборот.
4. Производительность и compiler optimizations
Заголовок раздела «4. Производительность и compiler optimizations»4.1 Stack vs Heap allocations
Заголовок раздела «4.1 Stack vs Heap allocations»Operation ns/op Notes-----------------------------------------Stack allocation ~0 just stack pointer adjustmentHeap allocation 10-50 mallocgc, write barriersGC cost varies 1-100% CPU overheadHeap allocations — основная причина latency и GC pressure. Каждый * reduces — выигрыш.
4.2 Как уменьшить escape
Заголовок раздела «4.2 Как уменьшить escape»- Передавай маленькие struct по value, не по pointer:
func sum(p Point) int { return p.X + p.Y } // value, no escapefunc sum2(p *Point) int { return p.X + p.Y } // pointer, может escape- Pre-allocate slices с известной capacity:
s := make([]int, 0, n) // cap=n, не растёт- Sync.Pool для temporary objects:
var bufPool = sync.Pool{ New: func() any { return &bytes.Buffer{} },}
func process() { buf := bufPool.Get().(*bytes.Buffer) defer bufPool.Put(buf) buf.Reset() // ...}- Избегать interface на горячем пути:
func sum(nums []int) int { ... } // direct, no boxingfunc sumI(nums []any) int { ... } // boxing every int4.3 Memory Model и compiler optimizations
Заголовок раздела «4.3 Memory Model и compiler optimizations»Compiler может переупорядочивать инструкции, если это не нарушает single-goroutine semantics. Между goroutines — без sync ничего не гарантировано.
var x, y int
go func() { x = 1 y = 2}()
go func() { if y == 2 { fmt.Println(x) // может быть 0! (compiler/CPU reorder) }}()Чтобы заработало — нужен sync (channel, mutex, atomic).
4.4 -race detector нюансы
Заголовок раздела «4.4 -race detector нюансы»- Тестируй с
-raceв CI. - Race не всегда воспроизводится (не deterministic).
- Race detector — false negative (могут пропустить race), но не false positive.
5. Когда использовать / когда НЕ использовать
Заголовок раздела «5. Когда использовать / когда НЕ использовать»Escape Analysis инсайты
Заголовок раздела «Escape Analysis инсайты»Использовать активно:
- При оптимизации hot paths.
- При работе с миллионами объектов.
- В библиотеках, нацеленных на performance.
Не зацикливаться:
- Premature optimization is the root of all evil.
- Большинство allocations в обычном коде не критичны.
- Сначала измерь (
go test -bench -benchmem, pprof), потом оптимизируй.
Memory Model
Заголовок раздела «Memory Model»- Всегда синхронизировать concurrent access (channel, mutex, atomic).
- Никогда не полагаться на “ну, скорее всего, увижу обновление”.
- Использовать
-raceв тестах.
6. Вопросы на собесе Middle 1
Заголовок раздела «6. Вопросы на собесе Middle 1»Q1: Что такое escape analysis?
A: Оптимизация компилятора Go: для каждой переменной определяет, может ли она жить на стеке или должна быть на heap. Если переменная не “ускользает” из функции — на стеке (быстрее, без GC).
Q2: Какие причины escape to heap?
A:
- Возврат указателя из функции.
- Захват в closure (включая goroutine, defer).
- Сохранение в interface (boxing).
- Большой размер (>~64KB).
- Slice/map с неизвестным размером (
make([]int, n)). - Передача в channel.
- Глобальная переменная.
Q3: Как посмотреть escape analysis?
A: go build -gcflags="-m" main.go — короткий вывод; -gcflags="-m -m" — подробный, с reasons.
Q4: Почему в Go можно вернуть указатель на локальную переменную?
A: Escape analysis перемещает переменную на heap. В отличие от C, stack memory не invalidates — компилятор гарантирует корректность.
Q5: Что такое stack growth в Go?
A: Goroutine стартует с маленьким стеком (8 KB). При нехватке — runtime выделяет новый, бóльший стек, копирует данные, обновляет указатели. Можно расти до 1 GB.
Q6: Влияет ли inlining на escape?
A: Да. Если функция инлайнена — компилятор видит весь код, лучше определяет escape. Часто inlined-функции не вызывают escape, не-inlined — вызывают.
Q7: Что такое Go Memory Model?
A: Формальные правила, описывающие, когда изменения в одной goroutine видны в другой. Основное понятие — happens-before: если A HB B, изменения A видны в B.
Q8: Что устанавливает happens-before?
A:
- Sequential execution в одной goroutine.
- Channel send HB receive.
- Mutex Unlock HB следующий Lock.
- sync.Once.Do HB всё после.
- Atomic (с Go 1.19+).
- Package init HB main.
Q9: Что такое data race?
A: Когда две goroutine обращаются к одной переменной concurrently, и хотя бы одна — запись, без синхронизации (без HB). Undefined behavior.
Q10: Как обнаружить race?
A: go run -race main.go или go test -race ./.... Race detector использует shadow memory и vector clocks. Overhead — 5-10x.
Q11: Channel close — happens-before что?
A: close(ch) happens-before любой receive из ch (включая receive of zero value после исчерпания).
Q12: Buffered channel — happens-before?
A: Для канала capacity C: k-й send HB (k+C)-й receive. Если C=0 (unbuffered) — k-й send HB k-й receive (синхронно).
Q13: sync.Mutex — happens-before?
A: n-й Unlock HB (n+1)-й Lock. То есть всё, что произошло до Unlock, видно после следующего Lock.
Q14: atomic.Store/Load — happens-before?
A: С Go 1.19+: atomic.Store HB atomic.Load (sequential consistency). До 1.19 — слабее, поведение зависело от платформы.
Q15: Будет ли race?
var x intgo func() { x = 1 }()fmt.Println(x)A: Да, классический race. Запись и чтение x без синхронизации.
Q16: Будет ли race?
var x intch := make(chan struct{})go func() { x = 1 close(ch)}()<-chfmt.Println(x)A: Нет. close(ch) HB receive, поэтому x = 1 HB Println(x). Гарантировано печатается 1.
Q17: Будет ли race?
var x intvar mu sync.Mutexgo func() { mu.Lock() x = 1 mu.Unlock()}()mu.Lock()fmt.Println(x)mu.Unlock()A: Нет race (если Lock в main вызван после goroutine Lock/Unlock). Но x может быть 0 или 1 — гонка по timing (если main lock’нул первым).
Q18: Что такое sync.Once?
A: Гарантирует, что функция выполнится ровно один раз, даже при concurrent вызовах из разных goroutine. Все вызывающие видят результат (happens-before).
Q19: Как Go оптимизирует defer?
A:
- Go 1.13+: open-coded defer для статических вызовов (в одном-двух местах). Без heap-allocated structure.
- Inlinable defers — на стеке.
- Сложные defers (в цикле) — heap-allocated.
Q20: Что такое sync.Pool?
A: Pool временных объектов для переиспользования. Помогает уменьшить heap allocations:
var p = sync.Pool{New: func() any { return &Buffer{} }}buf := p.Get().(*Buffer)defer p.Put(buf)GC может очистить pool (например, при STW). Не для долгоживущих объектов.
Q21: Почему inlining важен для производительности?
A: Inlining:
- Убирает function call overhead.
- Раскрывает escape analysis (компилятор видит весь контекст).
- Включает дополнительные оптимизации (constant folding, dead code elimination).
Без inlining — escape часто пессимистичен.
Q22: Может ли компилятор переупорядочить инструкции?
A: Да, если это не нарушает single-goroutine semantics. Между goroutine — без sync гарантий нет. Это причина существования memory model.
Q23: В чём цена heap allocation?
A:
- Сам malloc (~10-50 ns).
- Write barrier (для GC).
- Кеш-промахи (heap данные разбросаны).
- GC давление (больше мусора — чаще GC). В сумме — может быть x10 медленнее, чем stack.
Q24: Что такое write barrier?
A: Инструкция, выполняемая при записи указателя в heap-объект во время GC. Помогает GC отслеживать reachable objects. Включается на короткие промежутки (concurrent mark phase).
Q25: Как уменьшить GC pressure?
A:
- Меньше heap allocations (рассчитывай stacks, sync.Pool).
- Preallocate slices с правильным cap.
- Избегай interface boxing на горячем пути.
- Используй
GOGCдля тюнинга (default 100 — heap doubling). - Profile через
go tool pprof.
7. Practice — задачи
Заголовок раздела «7. Practice — задачи»Задача 1
Заголовок раздела «Задача 1»Где живёт s?
func F() []int { s := make([]int, 10) return s}Решение:
s (точнее, underlying array) — на heap. Slice возвращается, ускользает из функции.
Подтверждение: go build -gcflags="-m":
./main.go:2:11: make([]int, 10) escapes to heapЗадача 2
Заголовок раздела «Задача 2»Оптимизируйте:
func sumPairs(pairs []Pair) int { total := 0 for _, p := range pairs { var b Box = p // ← box — interface! total += b.Sum() } return total}Решение:
var b Box = p — boxing в interface, allocation per iteration. Если Pair реализует Sum() напрямую — убрать interface:
func sumPairs(pairs []Pair) int { total := 0 for _, p := range pairs { total += p.Sum() // direct call, no boxing } return total}Если нужен polymorphism — interface переходит на slice уровне:
func sumBoxes(boxes []Box) int { total := 0 for _, b := range boxes { // b — interface, но boxing уже сделан total += b.Sum() } return total}Задача 3
Заголовок раздела «Задача 3»Будет ли race?
type Counter struct{ val int }
c := Counter{}go func() { c.val++ }()go func() { c.val++ }()time.Sleep(time.Second)fmt.Println(c.val)Решение:
Да. Две goroutine инкрементируют c.val без синхронизации. Результат — может быть 1 (race), 2 (повезло), даже мусор (хотя int — atomic на x86, но на других платформах — не обязательно).
Решение — atomic.AddInt64 или sync.Mutex.
Задача 4
Заголовок раздела «Задача 4»Почему это медленно?
func process(items []any) int { sum := 0 for _, item := range items { if n, ok := item.(int); ok { sum += n } } return sum}Решение:
items []any— каждый int boxed вany(allocation при создании списка).- Type assertion
.(int)— runtime check.
Оптимизация:
func process(items []int) int { // прямо []int sum := 0 for _, n := range items { sum += n } return sum}Если нужно несколько типов — generics:
func process[T Number](items []T) T { ... }Задача 5
Заголовок раздела «Задача 5»Будет ли race?
ch := make(chan int)var x int
go func() { x = 1 ch <- 1}()
<-chgo func() { fmt.Println(x) // ← race?}()Решение:
Нет race между goroutine 1 и main: send HB receive, поэтому x = 1 HB receive.
Но между receive и goroutine 2 — HB есть (так как создание goroutine HB её выполнение). Поэтому fmt.Println(x) видит x = 1.
⚠️ Но если бы две goroutine читали x одновременно без HB между ними — был бы race.
Задача 6
Заголовок раздела «Задача 6»Найдите escape:
func newUsers(n int) []*User { users := make([]*User, 0, n) for i := 0; i < n; i++ { u := User{ID: i} users = append(users, &u) } return users}Решение:
make([]*User, 0, n)— slice escape (возвращается).u := User{ID: i}—&uберётся, добавляется в slice → escape per iteration.
То есть — N+1 heap allocations. Лучше:
func newUsers(n int) []*User { users := make([]*User, n) storage := make([]User, n) // один большой allocation for i := 0; i < n; i++ { storage[i] = User{ID: i} users[i] = &storage[i] } return users}Теперь 2 allocations вместо N+1.
Задача 7
Заголовок раздела «Задача 7»Будет ли race?
var initialized boolvar data []int
func Init() { if !initialized { data = []int{1, 2, 3} initialized = true }}
func GetData() []int { if !initialized { Init() } return data}Решение:
Да. initialized и data читаются/пишутся concurrent (если Init и GetData вызываются из разных goroutine).
Решение — sync.Once:
var once sync.Oncevar data []int
func Init() { once.Do(func() { data = []int{1, 2, 3} })}
func GetData() []int { Init() return data // once.Do HB this return → data видна}8. Источники
Заголовок раздела «8. Источники»- Go Memory Model — официальный документ: https://go.dev/ref/mem
- Go Blog — Allocator: avoiding allocations (серия про escape).
- Dave Cheney — Escape Analysis demystified — https://dave.cheney.net/2014/06/07/five-things-that-make-go-fast
- Go source —
cmd/compile/internal/escape/,runtime/mgc.go,runtime/proc.go. - The Go Blog — Updating the Go Memory Model (про atomic в Go 1.19+).
- William Kennedy — Escape Analysis in Go — серия talks.
- Habr 2024 — Stack vs Heap: escape analysis в Go.
- Russ Cox — Hardware Memory Models — https://research.swtch.com/hwmm (фундаментальное чтение).
- Kavya Joshi — Understanding Go Runtime (talks про память).
- Go 1.19 release notes — про новый memory model для atomic.