Модуль
1От исходника до регистров2Жизнь Go-программы: от запуска до завершения← вы здесь
Урок 2~15 минут

Жизнь Go-программы: от запуска до завершения

Компилятор превратил исходник в бинарник. Теперь посмотрим что происходит когда этот бинарник запускается.

Используй симулятор выше — там 5 сценариев с пошаговой анимацией. Ниже — детали каждого этапа.


1. Запуск процесса

bash
$ ./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() — последний в этой цепочке, не первый.

Порядок инициализации
Ctrl+Enter
1

2. Память: стек и куча

В Go аллокация происходит в двух местах:

┌──────────────────────────────────────┐ Высокий адрес
│  Goroutine Stack (2KB → растёт)      │
│  ─ локальные переменные              │
│  ─ аргументы функций                 │
│  ─ фреймы вызовов                    │
├──────────────────────────────────────┤
│  Heap (управляется GC)               │
│  ─ все объекты созданные через new() │
│  ─ make(slice/map/chan)               │
│  ─ переменные которые "убегают"      │
├──────────────────────────────────────┤
│  .bss / .data / .rodata / .text      │
└──────────────────────────────────────┘ Низкий адрес

Стек — быстро и бесплатно

go
func add(a, b int) int {
    result := a + b   // result на стеке — просто SP -= 8
    return result
}                     // фрейм освобождается — SP += 8

Стековая аллокация — это буквально сдвиг одного регистра. Никакого GC.

Куча — медленнее, но живёт дольше

go
func newUser(name string) *User {
    u := &User{Name: name}  // u "убегает" на кучу — возвращаем указатель
    return u
}

Escape analysis решает: если значение выходит за пределы функции — оно идёт на кучу.

Стек vs куча
Ctrl+Enter
1

Stack growth

Горутина начинает с 2 KB. При нехватке Go автоматически создаёт новый сегмент в 2× больше и копирует фреймы:

go
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. Балансировка автоматическая.

Горутины и планировщик
Ctrl+Enter
1

Блокировка и пробуждение

Когда горутина блокируется (channel, mutex, IO) — она переходит в состояние waiting. M освобождается и берёт другую G из очереди:

go
ch := make(chan int)
 
go func() {
    time.Sleep(100 * time.Millisecond) // горутина в waiting
    ch <- 42                           // пробуждает получателя
}()
 
val := <-ch  // текущая горутина паркуется до получения
fmt.Println(val)

Ни один OS-поток не простаивает. Поэтому Go может держать миллионы горутин при небольшом числе потоков.


4. Сеть и IO: netpoller

go
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()
Параллельные HTTP-запросы
Ctrl+Enter
1

Системные вызовы, которые блокируют 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
Ctrl+Enter
1

Настройка GC

bash
# GOGC=100 (default) — GC запускается когда heap вырос вдвое
GOGC=200 ./server   # реже GC, больше памяти
GOGC=50  ./server   # чаще GC, меньше памяти
GOGC=off ./server   # выключить GC (осторожно!)
 
# Go 1.19+: GOMEMLIMIT — жёсткий лимит памяти
GOMEMLIMIT=512MiB ./server

Инструменты для наблюдения за рантаймом

bash
# Трассировка выполнения (горутины, 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  # каждую секунду
runtime stats — снапшот состояния
Ctrl+Enter
1
GOMAXPROCS по умолчанию равен числу CPU. Но для IO-нагруженных задач его иногда увеличивают. GOMAXPROCS=1 запускает Go на одном ядре — полезно для отладки гонок.
ЭТАПЫ
1
OS: fork/exec
os
2
mmap бинарника
os
3
runtime·rt0_go
runtime
4
schedinit()
runtime
5
Инициализация heap
runtime
6
Создание main goroutine
runtime
7
mstart() → main()
user
🚀
Нажми ▶ Запустить
или кликни на любой шаг
os
runtime
user
network
gc
🎯
Миссия 1 из 5
Какой начальный размер стека у горутины в Go?