⚙️ Go Runtime изнутри: GMP, GC, escape analysis и memory model Почему Go "просто работает" быстро без ручного управлени
Почему Go "просто работает" быстро без ручного управления потоками — разбираем механику под капотом.
1. GMP-модель планировщика
Три сущности:
- G (Goroutine) — сама горутина: стек (растёт от 2KB), инструкция, статус
- M (Machine) — реальный OS-поток, который выполняет код
- P (Processor) — логический процессор, держит локальную очередь горутин (runqueue) и служит "разрешением" на выполнение
Ключевая идея: GOMAXPROCS задаёт число P, а не M. M может блокироваться на syscall — тогда P отвязывается от него и находит себе другой свободный/новый M. Это и есть секрет: блокирующий syscall не останавливает остальные горутины.
Work stealing: если у P опустела локальная очередь, он крадёт горутины у других P (обычно половину батча) или лезет в глобальную очередь. Это балансировка без централизованного шедулера.
Preemption: до Go 1.14 планировщик был кооперативным — горутина без вызовов функций могла зависнуть навечно в tight loop. С 1.14 добавлен асинхронный preemption через сигналы (SIGURG), горутину прерывают принудительно.
2. Escape Analysis
Компилятор решает на этапе компиляции — стек или куча:
func onStack() int {
x := 42
return x // не убегает — на стеке
}
func onHeap() *int {
x := 42
return &x // адрес утекает — уходит в кучу
}
Проверить реальность:
go build -gcflags="-m" main.go
Частые причины "утечки" на кучу: возврат указателя, передача в интерфейс, замыкание, которое переживает функцию, слишком большой объект (компилятор консервативен).
3. GC: Concurrent Mark & Sweep
Go использует tri-color mark-and-sweep с write barrier, работающий конкурентно с мутатором (вашей программой):
- White — потенциальный мусор
- Grey — найден, но дети не просканированы
- Black — жив, обработан полностью
Write barrier ловит запись указателя во время фазы маркировки, чтобы не потерять объект, если мутатор переставляет ссылки прямо во время сборки (проблема "затирания" грей-объекта).
STW (Stop-The-World) случается только дважды за цикл, и оба раза — микросекунды: старт (включить write barrier) и финиш (выключить, финализировать).
Триггер GC — GOGC (по умолчанию 100%: сборка запускается, когда куча выросла вдвое с прошлого цикла) и с Go 1.19 — GOMEMLIMIT для soft memory limit.
4. Memory Model: happens-before
Go memory model формально описывает, при каких условиях запись в одной горутине гарантированно видна чтению в другой. Без синхронизации — никаких гарантий, компилятор и процессор вправе переупорядочить операции.
Гарантии happens-before дают:
// 1. Channel
ch := make(chan int)
go func() {
data = 42 // (A)
ch <- 1 // (B) happens-before получение
}()
<-ch // (C)
_ = data // видит 42, т.к. A → B → C
// 2. Mutex
mu.Lock()
// критическая секция
mu.Unlock() // Unlock happens-before следующий Lock
// 3. sync.Once
var once sync.Once
once.Do(f) // f гарантированно выполнится один раз, видимо всем
Без синхронизации — это data race, даже если "по факту не ломается" на вашей машине. Проверяйте:
go run -race main.go
Гонка данных в Go — undefined behavior на уровне спецификации, а не просто "риск получить неверное значение".
GMP даёт дешёвую конкурентность, escape analysis решает, где жить переменной, GC работает конкурентно и почти без пауз, а memory model — это контракт, без соблюдения которого все остальные гарантии бессмысленны.
#golang #runtime #gc #scheduler #concurrency
👉 @golang_lib