Раздел 22 · Go

Языки и рантайм: где Go и как живёт программа

junior~45 мин

без входаВойди, чтобы отмечать прогресс, вести заметки и решать задачи в редакторе. войти

Перед этим уроком

Языки и рантайм: где Go и как живёт программа

Этот урок ставит фундамент перед всем остальным фундаментом. Тут не будет кода, который ты будешь писать сам, тут будет интуиция: что вообще такое “скомпилированный язык”, куда попадает Go в этой классификации, что лежит в памяти процесса, пока твоя программа работает, и где эта программа обычно живёт в индустрии. Если эти вещи в голове, всё дальнейшее (память, указатели, интерфейсы) ляжет в правильные ячейки.

После урока зайди в 02-memory-and-parameters, там карта памяти превратится в практику с &, * и реальными адресами.

1. Типы языков по способу исполнения

Программа на любом языке проходит путь от текста до выполнения. Способов этот путь устроить ровно три, и от выбранного способа зависит почти всё: скорость старта, размер бинаря, тип ошибок (которые ловишь на компиляции или в проде), профиль развёртывания.

ТипЧто делаетПримерыСтартРазмер артефакта
Компилируемый AOTТранслирует исходник в машинный код до запуска. Запускаешь готовый бинарь.C, C++, Rust, Go, Swiftмгновенныйодин файл
ИнтерпретируемыйЧитает исходник построчно во время выполнения, прямо так его и исполняет (или компилирует в байткод “на лету”).Python (CPython), Ruby, классический JS, Bashмедленныйисходники плюс рантайм
Гибридный (JIT/VM)Компилирует исходник в байткод, байткод исполняется виртуальной машиной, горячие участки JIT-компилируются в машинный код прямо в процессе.Java (JVM), C# (.NET), современный JS (V8), Erlang BEAMсредний (плюс прогрев)байткод плюс VM

AOT это не “лучше” и не “хуже”. У каждого режима свои сделки: AOT даёт быстрый старт и предсказуемую скорость, но теряет рантайм-оптимизации, которые JIT может включать на горячих циклах. Интерпретатор медленный, но он умеет править код в работающей программе (“hot reload”), не останавливая её.

Куда попадает Go

Go это компилируемый AOT-язык со встроенным runtime. Это не похоже ни на чистый C, ни на Java:

  • go build превращает исходник в машинный код для целевой платформы. Результат это самодостаточный бинарь: ОС, libc нужного ABI, и всё, никаких “виртуальных машин” не запускается.
  • Внутрь бинаря зашит Go runtime. Это маленькая библиотека (~1 MB на пустой hello world), в которой живут планировщик горутин, garbage collector, поддержка каналов и map-ов. Поэтому даже однострочный Go-бинарь весит несколько мегабайт, в отличие от 100-килобайтного C.
  • Никакого JIT. Go не оптимизирует код на ходу. То что компилятор сгенерировал, то и работает. Это проще, дешевле и предсказуемее, в обмен на потолок производительности на 10-20% ниже чем у JVM на долго работающих CPU-bound сервисах.

Эту сделку команда Go выбрала сознательно: они приоритизировали время старта, размер бинаря и читаемость профиля памяти в обмен на пиковую перформансную математику. Для серверных сервисов и CLI это почти всегда правильный размен.

2. Гибридные языки и почему Go не такой

Гибридные языки часто называют “лучшими из двух миров”. Идея в том, что:

  1. Компилируешь в байткод, более компактный чем исходник, но более высокоуровневый чем машинный код.
  2. Запускаешь VM, она исполняет байткод.
  3. JIT в VM наблюдает за горячими участками и на лету перекомпилирует их в машинный код.

Это объясняет, почему долго работающий Java-сервис может выйти на скорость близкую к C: после прогрева hot loops уже скомпилированы.

Python:   .py ──cpython─-> .pyc ─-> интерпретатор виртуальной машины ─-> результат
Java:     .java ──javac──-> .class ─-> JVM (байткод-интерпретатор + JIT C1/C2) ─-> результат
JavaScript: .js ──V8──-> Ignition байткод ──TurboFan JIT──-> машинный код ─-> результат

Go:       .go  ──gc compiler──-> машинный код плюс встроенный runtime

Чего у Go нет, и почему это нормально:

  • Нет VM, бинарь запускается напрямую ядром ОС.
  • Нет JIT, оптимизация целиком в go build.
  • Нет интерпретатора, нет go run --hot-reload. Изменил исходник, запустил go build снова. Это занимает миллисекунды на маленьких модулях (часть 1.18+ кэширует пакеты).

Главное практическое следствие: Go-программу нельзя “запустить как скрипт”. Если хочется быстрых итераций как в Python, ставят air или reflex, они смотрят за файлами и пересобирают бинарь, эффект, как у hot reload, на самом деле, обычная пересборка.

3. Что лежит в памяти запущенной программы

Когда ОС запустила твой Go-бинарь, в адресное пространство процесса попало несколько типов данных. Эта картинка одинаковая для C, Rust и Go (отличается только то, что Go положит сверху goroutine-стеки и heap-метаданные GC).

адрес  ╭───────────────────────────────╮  низ
       │ text     инструкции (read-only)│
       ├───────────────────────────────┤
       │ data     инициализированные    │
       │          глобальные            │
       ├───────────────────────────────┤
       │ bss      неинициализированные  │
       │          глобальные (zero)     │
       ├───────────────────────────────┤
       │ heap     ↓ растёт вниз         │
       │          (динамическая память) │
       │          ...                   │
       │          ↑ растёт вверх        │
       │ stack    стек главного потока  │
       ├───────────────────────────────┤
       │ runtime  Go scheduler, GC,     │
       │          goroutine-стеки       │
       ╰───────────────────────────────╯  верх
СегментЧто туда попадаетМожно ли менять
textскомпилированные машинные инструкции твоей программы и stdlibнет, read-only
dataглобальные переменные с явным значением: var debug = trueда
bssглобальные без явного значения: var counter int (получит 0)да
heapобъекты, которые “пережили” фрейм функции, slice/map с make, всё, на что есть указатель из других местда
stackлокальные переменные, фреймы вызовов функцийда, через push/pop

В Go к этой картинке добавляется ещё одна важная вещь: у каждой горутины свой стек, который растёт динамически. У главного потока (горутины main) он начинает с 8 KB, но рантайм может его расширить до сотен мегабайт, если функция вошла в глубокую рекурсию. Раньше Go-горутины росли сегментами, с 1.4 это contiguous-stack с копированием. Подробности в 17-concurrency-deep.

Стек vs heap, та самая граница

Главное практическое отличие, которое будет важно весь курс:

  • Стек, “переменная живёт пока живёт функция”. Аллокация это просто сдвиг указателя, освобождение тоже. Без участия GC.
  • Heap, “переменная переживёт функцию”. Аллокацию делает рантайм через mallocgc, освобождение делает garbage collector. Дороже в обе стороны.

Куда попадёт переменная, решает не программист, а escape analysis компилятора Go. Деталь разберём в 02-memory-and-parameters и углубим в 18-runtime-and-perf.

премиум-урок

Продолжение в премиуме: 1 200 ₽ в месяц или 10 000 ₽ в год. Вместе с ним серверные проверки задач и школьные токены AI-коуча.

Оплата пока не подключена: премиум и курсы открываются промокодом.