Жизнь Go-программы: от запуска до завершения
Компилятор превратил исходник в бинарник. Теперь посмотрим что происходит когда этот бинарник запускается.
Используй симулятор выше — там 5 сценариев с пошаговой анимацией. Ниже — детали каждого этапа.
1. Запуск процесса
$ ./helloТы думаешь что запускается main(). На самом деле первой выполняется функция runtime·rt0_go:
./hello
│
├─ OS: fork + exec → новый процесс, виртуальное адресное пространство
├─ OS: mmap ELF → .text/.data/.rodata загружены в память
│
├─ runtime·rt0_go (ассемблер)
│ ├─ Настройка g0 (служебная горутина с большим стеком)
│ ├─ Настройка TLS (Thread-Local Storage для g)
│ ├─ args() → argc/argv сохранены
│ └─ schedinit() → создание P (GOMAXPROCS штук)
│
├─ runtime·newproc(main·main) → создаётся main-горутина (2 KB стек)
└─ runtime·mstart() → M0 запускает планировщик → main()
main() — последний в этой цепочке, не первый.
2. Память: стек и куча
В Go аллокация происходит в двух местах:
┌──────────────────────────────────────┐ Высокий адрес
│ Goroutine Stack (2KB → растёт) │
│ ─ локальные переменные │
│ ─ аргументы функций │
│ ─ фреймы вызовов │
├──────────────────────────────────────┤
│ Heap (управляется GC) │
│ ─ все объекты созданные через new() │
│ ─ make(slice/map/chan) │
│ ─ переменные которые "убегают" │
├──────────────────────────────────────┤
│ .bss / .data / .rodata / .text │
└──────────────────────────────────────┘ Низкий адрес
Стек — быстро и бесплатно
func add(a, b int) int {
result := a + b // result на стеке — просто SP -= 8
return result
} // фрейм освобождается — SP += 8Стековая аллокация — это буквально сдвиг одного регистра. Никакого GC.
Куча — медленнее, но живёт дольше
func newUser(name string) *User {
u := &User{Name: name} // u "убегает" на кучу — возвращаем указатель
return u
}Escape analysis решает: если значение выходит за пределы функции — оно идёт на кучу.
Stack growth
Горутина начинает с 2 KB. При нехватке Go автоматически создаёт новый сегмент в 2× больше и копирует фреймы:
func deepRecursion(n int) int {
if n == 0 { return 0 }
return 1 + deepRecursion(n-1) // каждый вызов — новый фрейм
}
// При n=10000 стек вырастет с 2KB до ~160KB
// Для программиста это абсолютно прозрачно3. Планировщик: модель G-M-P
Go не создаёт OS-поток на каждую горутину. Вместо этого — трёхуровневая модель:
G (Goroutine) — код + стек + контекст
M (Machine) — OS-поток (реально выполняет инструкции)
P (Processor) — логический CPU (локальная очередь горутин)
P0 [ G1 running ] ← M0 выполняет G1
P1 [ G2 running ] ← M1 выполняет G2
[G3, G4, G5] ← очередь P0 — ждут своей очереди
Global queue: [G6, G7] ← если локальные очереди пусты
Work stealing: если P1 остался без G — он крадёт половину очереди у занятого P0. Балансировка автоматическая.
Блокировка и пробуждение
Когда горутина блокируется (channel, mutex, IO) — она переходит в состояние waiting. M освобождается и берёт другую G из очереди:
ch := make(chan int)
go func() {
time.Sleep(100 * time.Millisecond) // горутина в waiting
ch <- 42 // пробуждает получателя
}()
val := <-ch // текущая горутина паркуется до получения
fmt.Println(val)Ни один OS-поток не простаивает. Поэтому Go может держать миллионы горутин при небольшом числе потоков.
4. Сеть и IO: netpoller
resp, err := http.Get("https://api.example.com/data")Эта строка выглядит синхронной. На уровне OS — всё неблокирующее:
1. net.Dial → socket(O_NONBLOCK)
2. Горутина пытается read() → EAGAIN (данных нет)
3. Горутина регистрируется в epoll → паркуется (waiting)
4. M освобождается → выполняет другие горутины
5. epoll сообщает: FD готов → горутина → runnable
6. Планировщик → горутина продолжает read()
Системные вызовы, которые блокируют M
Некоторые syscalls Go не может сделать неблокирующими (например, файловый IO на Linux без io_uring):
goroutine вызывает os.ReadFile("big.log")
│
├─ entersyscall() → G отвязывается от P
├─ P передаётся другому M (или создаётся новый M)
├─ syscall read() блокирует M на время
└─ exitsyscall() → G возвращается в очередь P
Поэтому тысячи go os.ReadFile(...) в параллели безопасны — Go создаст нужное число потоков.
5. Сборщик мусора
GC в Go — конкурентный, трёхцветный, с минимальными паузами.
Tri-color marking
Белый — объект не посещён (потенциальный мусор)
Серый — объект найден, его поля ещё не проверены
Чёрный — объект и все его поля проверены (достижим)
Алгоритм:
1. Корни (globals, stacks) → серые
2. Серый объект → проверить поля → поля в серые → объект чёрный
3. Повторять пока серых нет
4. Все белые = мусор → освободить
Временная шкала GC цикла
Пользовательский код: ████████████████████████████████
GC marking: ░░░░░░░░░░░░░░░░░░░░
STW паузы: ■ ■
(включение WB) (финальный обход)
STW (Stop-The-World) паузы — обычно 50–100 микросекунд. Marking идёт параллельно с пользовательским кодом на выделенных P.
Настройка GC
# GOGC=100 (default) — GC запускается когда heap вырос вдвое
GOGC=200 ./server # реже GC, больше памяти
GOGC=50 ./server # чаще GC, меньше памяти
GOGC=off ./server # выключить GC (осторожно!)
# Go 1.19+: GOMEMLIMIT — жёсткий лимит памяти
GOMEMLIMIT=512MiB ./serverИнструменты для наблюдения за рантаймом
# Трассировка выполнения (горутины, GC, syscalls)
go test -trace trace.out ./...
go tool trace trace.out # открывает в браузере
# Профиль CPU
go test -cpuprofile cpu.out ./...
go tool pprof cpu.out
# Профиль памяти
go test -memprofile mem.out ./...
go tool pprof mem.out
# Статистика GC в реальном времени
GODEBUG=gctrace=1 ./server
# 2009/11/10 gc 1 @0.001s 0%: 0.013+0.23+0.010 ms clock...
# Планировщик
GODEBUG=schedtrace=1000 ./server # каждую секунду