Go Compiler, CGO и Assembly: глубокая часть
Здесь мы разбираем, что компилятор Go делает с твоим кодом: inlining, escape analysis, BCE, devirtualization, PGO. Затем — build constraints, conditional compilation. Дальше — CGO с его подводными камнями и pure-Go альтернативами. Наконец — основы Plan 9 assembly и
go:linkname, как linker-trick для приватных символов. На Middle 2 эти вопросы выходят на собесах в Авито/Яндекс/Тинькофф, когда уже разобрали runtime. Это уровень “знаю, как мой код превращается в машинный язык, понимаю, почему какие-то вещи нельзя делать в CGO, могу читать ассемблер runtime’а”.
Содержание
Заголовок раздела «Содержание»- Базовая концепция (для разогрева)
- Glubokoe погружение: compiler passes, build tags, CGO, assembly, linkname
- Подводные камни
- Производительность и реальные кейсы
- Вопросы на собесе Middle 2
- Practice
- Источники
1. Базовая концепция (разогрев)
Заголовок раздела «1. Базовая концепция (разогрев)»Go-компилятор (gc, не путать с garbage collector):
- Лексер + парсер → AST.
- Type checker.
- SSA (static single assignment) intermediate representation.
- Multiple optimization passes (inlining, escape, BCE, devirtualization, PGO).
- Machine code generation (asm-уровень).
- Linker (
link) — финальный binary.
Build tags позволяют условную компиляцию: //go:build linux, //go:build amd64 && !race. Файлы с суффиксами _linux.go, _amd64.go автоматически включаются.
CGO — мост к C-библиотекам. Полезно для SQLite, OpenSSL, libpq. Дорого: переход G→C блокирует M, нет автоматического GC поверх C-памяти.
Assembly в Go — Plan 9-style, отличается от Intel/AT&T. Файлы *_amd64.s. Использовать редко, но в runtime критично (атомики, SIMD, syscall).
go:linkname — directive для linker’а, позволяет ссылаться на private символы других пакетов. С Go 1.23+ ограничения сильно ужесточены.
2. Глубокое погружение
Заголовок раздела «2. Глубокое погружение»2.1. Стадии компиляции
Заголовок раздела «2.1. Стадии компиляции»*.go files │ ▼[parser] ─► AST (abstract syntax tree) │ ▼[typecheck] ─► типизированный AST │ ▼[walk] ─► упрощённый AST (replace high-level operators, e.g. map ops → runtime calls) │ ▼[SSA conversion] ─► SSA form (static single assignment) │ ▼[SSA optimization passes] ├─ dead code elimination ├─ common subexpression elimination ├─ value range analysis ├─ bounds check elimination ├─ inlining (расширение функций inline) ├─ escape analysis (heap vs stack) ├─ devirtualization (interface call → static call) ├─ PGO inlining adjustment (Go 1.21+) │ ▼[machine code generation] ─► *.o object files │ ▼[linker] ─► binaryПросмотр результатов:
go build -gcflags="-m" # inlining / escapego build -gcflags="-m=2" # подробнееgo build -gcflags="-S" # ассемблерgo tool compile -W # SSA passesGOSSAFUNC=foo go build . # HTML SSA для функции foo2.2. Inlining
Заголовок раздела «2.2. Inlining»Цель: заменить function call на тело функции в месте вызова. Экономит function call overhead (~5 ns) и открывает дополнительные оптимизации (constant folding в caller’е).
Решение принимается на основе budget:
- Каждая функция имеет “цену” в условных единицах.
- Простые операторы (+, -, *, /) = 1.
- Function calls внутри = ~57 + аргументы.
- Default budget = 80.
Если функция стоит ≤ 80 — inlined automatically. Можно подсветить:
go build -gcflags="-m -m" main.go 2>&1 | grep "can inline"⚠️ Что запрещает inlining:
- Recursive calls.
select,switch,for range(некоторые случаи).- Closures (раньше — всегда, сейчас иногда инлайнятся).
- Calls с
unsafe.Pointer. - Method calls через interface (require devirtualization first).
//go:noinline директива — запрещает inlining конкретной функции (для тестов, бенчмарков).
2.3. Bounds check elimination (BCE)
Заголовок раздела «2.3. Bounds check elimination (BCE)»Каждый array/slice access s[i] имеет неявный bounds check:
// s[i]:if uint(i) >= uint(len(s)) { runtime.panicIndex(i, len(s)) }return s[i]Этот check стоит ~1-2 ns. Компилятор стремится удалить его, когда может доказать, что i в пределах:
for i := 0; i < len(s); i++ { _ = s[i] // BCE: компилятор знает i < len(s)}
n := len(s)for i := 0; i < n; i++ { _ = s[i] // НЕ BCE: n не связан с len(s) для компилятора}⚠️ Capture len(s) в переменную может убить BCE (особенно если s потом меняется).
Проверка:
go build -gcflags="-d=ssa/check_bce/debug=1" main.goПокажет, где остались bounds checks.
Trick для оптимизации:
// до:for i := 0; i < len(s); i++ { s[i] = s[i+1] // i+1 — компилятор не уверен, BC остаётся}
// после:_ = s[len(s)-1] // hint, что весь диапазон валиденfor i := 0; i < len(s)-1; i++ { s[i] = s[i+1] // BCE сработает}2.4. Devirtualization (Go 1.19+)
Заголовок раздела «2.4. Devirtualization (Go 1.19+)»Interface call = indirect call через itab (interface table). Это ~5 ns + cache misses. Если компилятор может доказать конкретный тип — заменяет на static call.
var w io.Writer = os.Stdoutw.Write(p) // в Go 1.19+ может быть devirtualized: os.Stdout.Write(p)Условия:
- Compiler видит конкретный тип в той же compilation unit.
- Type не меняется между assign и use.
⚠️ Devirtualization умеет только static types. Если ты:
w := func() io.Writer { if cond { return os.Stdout } else { return os.Stderr }}()w.Write(p) // нет devirt— невозможно дегвиртуализировать.
2.5. Dead code elimination
Заголовок раздела «2.5. Dead code elimination»Компилятор находит код, который никогда не выполнится:
if false { expensive() // удалено}
const debug = falseif debug { fmt.Println("debug") // удалено}Это используется для conditional compilation через константы (вместо build tags для простых случаев).
2.6. Escape analysis
Заголовок раздела «2.6. Escape analysis»Решает: переменная остаётся на стеке (free) или escape’ит в heap (требует allocation + GC).
Эвристики:
- Адрес переменной возвращается из функции → escape.
- Адрес сохранён в global / поле escaped struct → escape.
- Передан в interface → escape (потому что interface boxing требует heap).
- Передан в goroutine (
go func() { use(&v) }()) → escape. - Sent в channel → escape (если value содержит pointer).
- Слайс растёт через append, оригинал был на стеке → escape.
go build -gcflags="-m" main.goПокажет:
./main.go:5: moved to heap: x./main.go:8: &x escapes to heap⚠️ Escape — не плохо само по себе. Но в hot path избыточный escape = больше GC pressure. Тщательная типизация (concrete types вместо interfaces) часто уменьшает escape.
2.7. Profile-guided optimization (PGO, Go 1.21+)
Заголовок раздела «2.7. Profile-guided optimization (PGO, Go 1.21+)»Идея: компилятор использует runtime profile (CPU pprof) для оптимизации. Знает горячие функции, горячие call sites — увеличивает их inlining budget.
Поток:
- Запустить prod-сервис с pprof CPU profile.
- Скачать profile (
go tool pprof -proto > default.pgo). - Положить в корень модуля под именем
default.pgo. - Build:
go build .(PGO применится автоматически).
Результат: 2-7% улучшение throughput на реальных сервисах (Go-team replicate в pkg.go.dev, Uber, Cloudflare).
⚠️ Profile должен быть репрезентативным (production-like нагрузка). Bad profile может ухудшить performance.
2.8. Inspect SSA
Заголовок раздела «2.8. Inspect SSA»GOSSAFUNC=main.foo go build .# создаёт ssa.html с интерактивным просмотром каждой SSA passОткрыть в браузере — посмотришь, как функция трансформируется через 30+ passes. Очень полезно для оптимизаций.
2.9. Build constraints
Заголовок раздела «2.9. Build constraints»Syntax (Go 1.17+):
Заголовок раздела «Syntax (Go 1.17+):»//go:build linux && amd64
package mypkgСтарый syntax (// +build) тоже работает, но deprecated с 1.17. Современные проекты — //go:build.
Что можно:
Заголовок раздела «Что можно:»//go:build linux // только Linux//go:build linux || darwin // Linux или macOS//go:build linux && (amd64 || arm64) // Linux на 64-bit//go:build !race // если НЕ race//go:build go1.22 // Go 1.22+//go:build mytag // пользовательский tagФайловые суффиксы:
Заголовок раздела «Файловые суффиксы:»foo_linux.go // только linuxfoo_amd64.go // только amd64foo_linux_amd64.go // только linux + amd64foo_test.go // только при `go test`Кастомные tags:
Заголовок раздела «Кастомные tags:»go build -tags="dev" # включает //go:build devgo build -tags="dev,verbose" # несколькоgo build -tags="prod !dev" # prod и без devПриложения:
Заголовок раздела «Приложения:»//go:build debug
package mylib
const enableDebug = true
// release.go//go:build !debug
package mylib
const enableDebug = falseИспользование:
if enableDebug { // компилятор уберёт, если const false log.Print(...)}OS-specific code:
Заголовок раздела «OS-specific code:»//go:build linux
package fs
func FAdvise(fd int) error { return syscall.Fadvise(...) }
// fs_other.go//go:build !linux
package fs
func FAdvise(fd int) error { return nil } // no-op2.10. CGO: зачем и как
Заголовок раздела «2.10. CGO: зачем и как»CGO позволяет вызывать C-код из Go. Простейший пример:
package main
/*#include <stdio.h>
void hello(const char* name) { printf("Hello, %s!\n", name);}*/import "C"
func main() { C.hello(C.CString("World"))}CGO активно используется для:
- SQLite (
github.com/mattn/go-sqlite3). - OpenSSL.
- libpq (PostgreSQL —
github.com/lib/pqиспользует CGO). - Системные библиотеки (libpcap, libfuse).
- Машинное обучение (TensorFlow, ONNX runtime).
2.11. CGO под капотом
Заголовок раздела «2.11. CGO под капотом»Когда Go-горутина вызывает C-функцию:
- Runtime отвязывает горутину от P (как при syscall, через
cgocall). - Стек переключается с Go-стека на C-стек (отдельный, ~8 MB по-умолчанию).
- M исполняет C-код. P может быть переотдан другой M.
- C возвращает → Runtime пытается схватить P обратно.
- Goroutine продолжается на Go-стеке.
⚠️ Это дорого: ~50-200 ns на CGO call просто overhead. Плюс — Go scheduler не контролирует M, пока он в C.
2.12. CGO подводные камни
Заголовок раздела «2.12. CGO подводные камни»1. Pointer passing rules
Заголовок раздела «1. Pointer passing rules»Go pointer нельзя хранить в C-памяти. GC не видит C-памяти, объект может быть собран:
goVar := myStruct{}C.set_callback(&goVar) // ⚠️ ОПАСНО: C хранит &goVar, GC может собратьПравильно — через cgo.Handle:
h := cgo.NewHandle(&goVar)C.set_callback(unsafe.Pointer(h))// ...// в callback:// func goCallback(h C.uintptr_t) {// ref := cgo.Handle(h).Value().(*myStruct)// ...// }h.Delete() // освобождаем, когда не нужноcgo.Handle (Go 1.17+) держит ссылку на Go-объект в runtime’е, GC не уберёт пока handle жив.
2. C-allocated memory не виден GC
Заголовок раздела «2. C-allocated memory не виден GC»ptr := C.malloc(1024)defer C.free(ptr)GC не отслеживает эту память. Утечки = твоя ответственность.
3. Long-running C calls блокируют M
Заголовок раздела «3. Long-running C calls блокируют M»Если C-функция выполняется > 20ms, sysmon переотдаст P. Если есть много long C-calls — может вырасти thread count.
# Видно черезGODEBUG=schedtrace=1000 ./mybinary# threads растёт быстро4. CGO_ENABLED=0 не работает с CGO
Заголовок раздела «4. CGO_ENABLED=0 не работает с CGO»CGO_ENABLED=0 go build .Это режим pure-Go. Если код использует import "C" — build fail. Полезно для:
- Static binaries в Docker
FROM scratch. - Cross-compilation без C-toolchain.
5. Cross-compilation pain
Заголовок раздела «5. Cross-compilation pain»Для кросс-компиляции с CGO нужен C-toolchain для целевой архитектуры:
# для linux/arm64 на macOSCGO_ENABLED=1 GOOS=linux GOARCH=arm64 CC=aarch64-linux-gnu-gcc go build .Это ад. Решение: pure-Go альтернативы.
6. Static linking тоже непросто
Заголовок раздела «6. Static linking тоже непросто»CGO_ENABLED=1 go build -ldflags '-extldflags "-static"' .Зависит от того, как линкуется libc (glibc vs musl). На Alpine (musl) проще, на Debian (glibc) — могут быть проблемы.
2.13. Замены CGO (pure-Go)
Заголовок раздела «2.13. Замены CGO (pure-Go)»| C-library | CGO version | Pure-Go alternative |
|---|---|---|
| libpq | github.com/lib/pq | github.com/jackc/pgx (рекомендуется) |
| sqlite3 | github.com/mattn/go-sqlite3 | modernc.org/sqlite |
| OpenSSL | github.com/spacemonkeygo/openssl | crypto/tls (stdlib) |
| zlib | github.com/mongodb/zstd (CGO) | compress/gzip (stdlib) |
| libcurl | через CGO | net/http |
Pure-Go обычно медленнее (1.5-2x для крипты), но даёт:
- Static binaries.
- Простую cross-compilation.
- Нет GC issues с C-памятью.
- Меньше attack surface (нет C-vulnerabilities).
В большинстве production кейсов pure-Go — правильный выбор.
2.14. CGO callbacks: Go → C → Go
Заголовок раздела «2.14. CGO callbacks: Go → C → Go»Нужно тогда, когда C-библиотека требует callback:
//export goCallbackfunc goCallback(value C.int) { fmt.Println("got:", value)}
// в C-части:// extern void goCallback(int);// void doWork() {// for (int i = 0; i < 10; i++) {// goCallback(i);// }// }При callback C→Go:
- CGO runtime знает, что мы в C-режиме.
- Переключается на Go-стек.
- Захватывает P (может ждать!).
- Вызывает Go-функцию.
- Возвращается в C.
⚠️ Если callback вызывается часто из tight C-loop — overhead огромный. Лучше batch’и: C собирает результаты, один callback возвращает массив.
2.15. Go assembly: Plan 9 syntax
Заголовок раздела «2.15. Go assembly: Plan 9 syntax»Go использует Plan 9-style assembly, не Intel и не AT&T. Это собственный стиль с уникальными directives.
Структура файла:
Заголовок раздела «Структура файла:»#include "textflag.h"
TEXT ·myFunc(SB), NOSPLIT, $0-16 MOVQ a+0(FP), AX // загрузка аргумента a (offset 0) MOVQ b+8(FP), BX // загрузка аргумента b (offset 8) ADDQ BX, AX MOVQ AX, ret+0(FP) // ⚠ ret offset depends — see below RETDirectives:
Заголовок раздела «Directives:»TEXT ·myFunc(SB), flags, $framesize-argsize— объявление функции.·— package qualifier. SB — static base (ссылка на символ).NOSPLIT— функция не имеет stack growth check (для маленьких функций).NOFRAME— нет frame pointer.WRAPPER— wrapper function (для defer).
Регистры:
Заголовок раздела «Регистры:»- На amd64: AX, BX, CX, DX, BP, SP, DI, SI, R8-R15.
- На arm64: R0-R30, RSP.
FP, SB, SP:
Заголовок раздела «FP, SB, SP:»- FP (frame pointer) — pseudo-register для доступа к аргументам и return values.
a+0(FP)= первый аргумент по смещению 0. - SB (static base) — pseudo для глобальных символов.
- SP — текущий stack pointer (но в Plan 9 это псевдо! Реальный SP —
SPбез префикса).
Calling convention:
Заголовок раздела «Calling convention:»До Go 1.17 — все аргументы и returns через стек (с FP). С 1.17 — ABIInternal (регистровая), но Go обычно сам делает wrapper’ы (ABI0 ↔ ABIInternal).
В assembly пишем обычно через FP, что соответствует ABI0:
TEXT ·Add(SB), NOSPLIT, $0-24 MOVQ a+0(FP), AX MOVQ b+8(FP), BX ADDQ BX, AX MOVQ AX, ret+16(FP) RET2.16. //go:noescape и //go:nosplit
Заголовок раздела «2.16. //go:noescape и //go:nosplit»//go:noescape
Заголовок раздела «//go:noescape»Для assembly-функций без Go-тела (часто SIMD). Говорит компилятору: эта функция не делает escape своих аргументов, можно оставить на стеке.
//go:noescape//go:linkname add internal/asmpkg.addfunc add(a, b uint64) uint64
// реализация в add_amd64.s//go:nosplit
Заголовок раздела «//go:nosplit»Запрещает stack growth check в прологе. Для очень маленьких functions, где fontainer-check был бы overhead. Используется в runtime’е активно.
⚠️ Если nosplit-функция действительно расширяет стек — ВЕРОЯТНО stack overflow без обнаружения. Использовать осторожно.
2.17. //go:linkname
Заголовок раздела «2.17. //go:linkname»Это очень мощная directive. Позволяет ссылаться на private символы других пакетов (бы что-то приватное):
package mypkg
import _ "unsafe" // нужен для linkname
//go:linkname runtimeNow runtime.nowfunc runtimeNow() (sec int64, nsec int32, mono int64)
func MyFunc() { s, n, _ := runtimeNow() ...}Это позволяет вызывать runtime.now — приватную функцию runtime’а — извне!
Используется в stdlib:
sync/atomic←runtime(для memory ordering).time←runtime(для monotonic clock).os/exec←os(для FD inheritance).
⚠️ С Go 1.23+ strict checking: linkname на runtime символы из user-кода работает только если “approved” (есть в списке в cmd/compile). Это сделано, потому что многие пакеты использовали linkname как “хак”, и при изменении runtime ломались. Сейчас Go team говорит: “это запрещено вне stdlib”.
С Go 1.25 — ещё строже. Cильное запрещение, может пропадает в Go 2.x.
2.18. Runtime intrinsics
Заголовок раздела «2.18. Runtime intrinsics»Некоторые функции компилятор знает специально — они не вызываются как обычные, а инлайнятся напрямую в machine code:
runtime.memmove→ CALL без обычного call overhead.- Атомики (
atomic.AddInt64,atomic.LoadPointer) → CMPXCHG / LOCK XADD напрямую. - Hash functions (
runtime.memhash) → специальный код, основан на AES-NI / SSE. - Some math (
math.Sqrt,math.Abs) → одна SSE-инструкция. unsafe.Sizeof,unsafe.Alignof— compile-time constants.
Это даёт zero-overhead для критичных операций.
2.19. SIMD через assembly
Заголовок раздела «2.19. SIMD через assembly»Стандартная либа использует SIMD для крипты:
chacha20—crypto/internal/chacha20.sha256— SHA-NI и AVX2.aes— AES-NI.
Пример (упрощённо, chacha20):
TEXT ·xorKeyStreamVX(SB), NOSPLIT, $0 ... VPXOR Y0, Y4, Y0 // 256-bit XOR VMOVDQU Y0, 0(DI) // store 32 bytes ...Reading чужого assembly в Go — это src/runtime/asm_amd64.s, src/crypto/*/asm_amd64.s.
2.20. Без assembly можно жить
Заголовок раздела «2.20. Без assembly можно жить»Большинство Middle 2 разработчиков не пишут assembly. Достаточно:
- Понимать, что runtime-функции (atomics, hash) — это assembly intrinsics.
- Уметь читать stack trace с asm-уровнем (например, при panic в
runtime.memmove). - Знать про
//go:noescapeи//go:linkname(что они есть, когда видишь).
Но если занимаешься performance-критичным кодом — основы assembly помогают понимать generated code от компилятора.
3. Подводные камни
Заголовок раздела «3. Подводные камни»3.1. ⚠️ Inlining budget — сюрприз для performance
Заголовок раздела «3.1. ⚠️ Inlining budget — сюрприз для performance»func Big() { // ~200 line function}
func Small() { Big() }Big() не inlinable (превышает budget). Small() тоже не выиграет от inlining Big’а.
Если ты ожидал inlining — проверь через -m=2.
3.2. ⚠️ //go:noinline для бенчмарков
Заголовок раздела «3.2. ⚠️ //go:noinline для бенчмарков»Без него компилятор может заинлайнить замеряемую функцию, и бенчмарк покажет ноль. Стандартный паттерн:
//go:noinlinefunc target() { ... }
func BenchmarkTarget(b *testing.B) { for i := 0; i < b.N; i++ { target() }}3.3. ⚠️ Bounds check возвращается из-за переменной
Заголовок раздела «3.3. ⚠️ Bounds check возвращается из-за переменной»s := make([]int, 100)for i := 0; i < 100; i++ { s[i] = i // BCE: i < 100 < len(s) ⇒ BC eliminated}
for i := 0; i < count; i++ { // count из аргументов s[i] = i // BC сохранится (count может быть > len(s))}3.4. ⚠️ Escape через interface{}
Заголовок раздела «3.4. ⚠️ Escape через interface{}»var x int = 42fmt.Println(x) // x escape'ит, потому что fmt.Println(args ...any)Любая передача в any — обычно escape. В hot path избегать (использовать typed callbacks).
3.5. ⚠️ PGO с bad profile
Заголовок раздела «3.5. ⚠️ PGO с bad profile»Если profile собран с нерелевантной нагрузкой (например, dev environment вместо prod) — PGO может ухудшить performance. Проверяй diff бенчмарков с и без PGO.
3.6. ⚠️ Build tags неправильный синтаксис
Заголовок раздела «3.6. ⚠️ Build tags неправильный синтаксис»//go:build linux,amd64 // ⚠️ ОШИБКА (нужно &&)
//go:build linux && amd64 // правильноСтарый +build использовал запятые. Новый //go:build — логические операторы.
3.7. ⚠️ CGO в Docker
Заголовок раздела «3.7. ⚠️ CGO в Docker»FROM golang:1.22 AS buildCOPY . .RUN go build . # ⚠️ если используется CGO, нужен gcc
FROM scratchCOPY --from=build /app /appENTRYPOINT ["/app"]scratch не имеет libc. Если CGO=1 → dynamic linking к libc → fail.
Решение: CGO_ENABLED=0 (если возможно) или multi-stage с Alpine (musl libc, static).
3.8. ⚠️ CGO callbacks в горячем цикле
Заголовок раздела «3.8. ⚠️ CGO callbacks в горячем цикле»C-loop, который вызывает Go callback миллион раз → миллион переключений контекстов → 200 ns × 1M = 200 ms overhead.
Решение: batch (массив результатов в C, один callback с массивом).
3.9. ⚠️ unsafe.Pointer в CGO ≠ C-указатель напрямую
Заголовок раздела «3.9. ⚠️ unsafe.Pointer в CGO ≠ C-указатель напрямую»unsafe.Pointer — это абстрактный pointer для Go. C.uintptr_t — C-pointer. Конверсии: unsafe.Pointer(uintptr(p)). Не путать.
3.10. ⚠️ Plan 9 assembly не совместима с Intel/AT&T
Заголовок раздела «3.10. ⚠️ Plan 9 assembly не совместима с Intel/AT&T»// Intel:mov rax, [rbx+8]
// Plan 9:MOVQ 8(BX), AXОбратный порядок операндов. Регистры без префикса r/e. Q = quadword (64-bit). L = longword (32-bit).
3.11. ⚠️ go:linkname ломается на новых версиях
Заголовок раздела «3.11. ⚠️ go:linkname ломается на новых версиях»Если ты используешь linkname к private runtime symbol — следующее обновление Go может убрать/переименовать символ, твой код упадёт. Кстати, с 1.23+ это в принципе запрещено вне stdlib.
3.12. ⚠️ Cross-compile с CGO
Заголовок раздела «3.12. ⚠️ Cross-compile с CGO»Cross-compile pure-Go = GOOS=linux GOARCH=arm64 go build . — работает.
Cross-compile с CGO = нужен C cross-compiler. Часто проще использовать Docker:
FROM --platform=$BUILDPLATFORM golang:1.22 AS buildARG TARGETOS TARGETARCHRUN GOOS=$TARGETOS GOARCH=$TARGETARCH CGO_ENABLED=0 go build .3.13. ⚠️ NOSPLIT-функция с большим frame
Заголовок раздела «3.13. ⚠️ NOSPLIT-функция с большим frame»TEXT ·foo(SB), NOSPLIT, $1024-0 // 1024-байт frame, без stack growth check!Если стек горутины ~2 KB начальный, и foo резервирует 1024 — может быть всего 1 KB запаса. Следующая функция упирается в guard — fatal “morestack on g0” или подобное.
NOSPLIT только для функций с frame < 96 байт (примерно).
3.14. ⚠️ Inlining + escape
Заголовок раздела «3.14. ⚠️ Inlining + escape»Иногда inlined function escape-ит свои аргументы, что увеличивает escape в caller’е. Bench показывает, что non-inlined быстрее. Очень редко, но бывает.
3.15. ⚠️ runtime.LockOSThread для CGO callback
Заголовок раздела «3.15. ⚠️ runtime.LockOSThread для CGO callback»Если C-библиотека делает callback в Go, и callback ожидает thread-local state (e.g., GLX context для OpenGL) — обязательно runtime.LockOSThread() в main.
func main() { runtime.LockOSThread() defer runtime.UnlockOSThread() // GLX setup C.startMainLoop() // C вызывает Go callbacks на этом же thread}4. Производительность и реальные кейсы
Заголовок раздела «4. Производительность и реальные кейсы»4.1. PGO дал +5% throughput
Заголовок раздела «4.1. PGO дал +5% throughput»Кейс (e-commerce checkout): после внедрения PGO с production profile, throughput вырос на 5.2%, P99 latency упала на 8%. Стоимость — генерация profile + extra build step.
4.2. Замена CGO sqlite на pure-Go
Заголовок раздела «4.2. Замена CGO sqlite на pure-Go»Кейс (CLI tool): использовали mattn/go-sqlite3 (CGO). Cross-compile для Windows/Mac/Linux — три CI matrix. Static binary не получался.
Перешли на modernc.org/sqlite. Скорость упала на ~30% (sqlite queries), но:
- CGO_ENABLED=0 везде.
- Один Dockerfile.
- Простой cross-compile.
- Binary 2 MB вместо 8 MB.
4.3. SIMD chacha20 на ARM64
Заголовок раздела «4.3. SIMD chacha20 на ARM64»Кейс (TLS-handling): на серверах M1 (ARM64) Go сам выбирал ARM64-asm для chacha20. Throughput 3 Gbit/sec. На amd64 с AVX2 — 5 Gbit/sec. Без SIMD — 800 Mbit/sec.
4.4. linkname для performance hack
Заголовок раздела «4.4. linkname для performance hack»Кейс (микросервис с custom time): нужны были monotonic timestamps в naseconds resolution. time.Now() возвращает Time с monotonic clock — но извлечь uint64 ns нет публичного API.
Через linkname к runtime.nanotime — extract напрямую. Overhead time.Now ~30 ns, nanotime ~5 ns. На 1M ops/sec — выигрыш 25 ms/sec CPU.
⚠️ С Go 1.23 это деприкейтнули. Сейчас разрешено только в самой стандартной либе.
4.5. Build tag для feature flags
Заголовок раздела «4.5. Build tag для feature flags»Кейс (мобильное SDK на Go): разные builds для feature gates.
//go:build !nosocialpackage myapp// social featuresBuild без social:
go build -tags="nosocial" .Binary меньше на 2 MB, нет dependency-зависимостей social-фич.
4.6. inline assertion для critical path
Заголовок раздела «4.6. inline assertion для critical path»Кейс (low-latency trading): hot function 200 ns/op. После переписывания с использованием //go:noinline для НЕ-critical helper functions + ensure inlining for critical ones (через manual splitting) — 130 ns/op.
Контроль через -gcflags="-m=2" обязателен.
4.7. Devirtualization win
Заголовок раздела «4.7. Devirtualization win»Кейс (Go 1.19 release): сервис с тысячами io.Writer calls. Compiler в 1.19 научился devirtualize, throughput вырос на 4-7%.
5. Вопросы на собесе Middle 2
Заголовок раздела «5. Вопросы на собесе Middle 2»Q1. Опишите стадии Go compiler.
Parser → typecheck → walk → SSA conversion → SSA optimization passes (inlining, escape, BCE, DCE, devirt) → machine code → linker.
Q2. Что такое inlining и какой budget?
Подстановка тела функции в caller. Budget 80 условных единиц (простой operator = 1, function call = 57). Превышение — нет inlining. Управление через //go:noinline.
Q3. Что такое BCE и как проверить?
Bounds check elimination — удаление неявных if i >= len(s) { panic } если компилятор уверен, что safe. Проверка: go build -gcflags="-d=ssa/check_bce/debug=1".
Q4. Что такое devirtualization и когда появилось?
Замена interface call (через itab) на static call. Появилось в Go 1.19. Условие: compiler видит конкретный тип в той же compilation unit.
Q5. Что такое escape analysis?
Решение compile-time: переменная на стеке или в heap. Escape: возврат адреса, передача в interface, в goroutine, в channel. Без escape — free allocation на стеке.
Q6. Как посмотреть escape analysis?
go build -gcflags="-m" main.go — базовый вывод. -m=2 — детальный.
Q7. Что такое PGO и сколько даёт?
Profile-guided optimization (Go 1.21+). Compiler использует CPU profile для оптимизации (inlining hot functions агрессивнее). На прод-сервисах +2-7% throughput.
Q8. Какой синтаксис build tags современный?
//go:build linux && amd64. Логические &&, ||, !. Старый // +build deprecated с 1.17.
Q9. Что такое суффиксы _linux.go, _amd64.go?
Файлы с этими суффиксами автоматически имеют implicit build constraint. _linux.go = //go:build linux, без надобности писать явно.
Q10. Зачем CGO?
Вызов C-библиотек: SQLite, OpenSSL, libpq, GUI-frameworks. Pure-Go альтернативы часто медленнее, но проще в deploy.
Q11. Какие подводные камни CGO?
- Дорогой переход G→C (50-200 ns).
- Go pointers нельзя хранить в C-памяти (GC не видит).
- Long C-calls блокируют M, sysmon выделяет новые threads.
- Cross-compile сложно (нужен C cross-compiler).
- Static link тоже сложно (libc dependencies).
Q12. Что такое cgo.Handle?
Способ передать Go-объект в C-код без риска GC. cgo.NewHandle(obj) возвращает opaque uintptr, который C хранит. Handle.Value() возвращает оригинальный объект. Runtime держит ref пока handle жив.
Q13. Почему CGO_ENABLED=0 нужен для Docker scratch?
scratch не имеет libc. С CGO компилятор линкует к libc (dynamic). Static linking с CGO работает с musl (Alpine), но сложен с glibc.
Q14. Альтернативы CGO для PostgreSQL?
github.com/jackc/pgx — pure-Go, рекомендуется. Используется в production Tinkoff/Avito.
Q15. Альтернативы CGO для SQLite?
modernc.org/sqlite — pure-Go (автогенерирован из C через c2go). Медленнее CGO версии на ~30%, но static binary, простой cross-compile.
Q16. Какой assembly использует Go?
Plan 9 syntax. Отличается от Intel/AT&T. Регистры без префикса (AX, не RAX). Операнды в обратном порядке от Intel.
Q17. Что такое NOSPLIT?
Директива в asm-функции: убрать stack growth check в прологе. Для очень маленьких функций (< 96 байт frame).
Q18. Что такое //go:noescape?
Директива для asm-функции без Go-тела. Говорит компилятору: аргументы не escape’ят, можно оставить на стеке у caller’а.
Q19. Что такое //go:linkname?
Linker-trick: ссылка на private символ другого пакета. Используется в stdlib (atomic ← runtime, time ← runtime). С Go 1.23+ запрещено вне stdlib.
Q20. Зачем import _ "unsafe" при использовании linkname?
Это требование компилятора. Linkname считается “unsafe” фичей.
Q21. Что такое intrinsics?
Функции, которые компилятор знает специально — заменяет их вызов на инлайн machine code. Примеры: atomic.AddInt64, memmove, hash functions, math.Sqrt.
Q22. Где SIMD используется в Go stdlib?
В crypto: chacha20, sha256 (SHA-NI), aes (AES-NI). Файлы *_amd64.s, *_arm64.s.
Q23. Какой overhead у interface call?
Обычно ~5 ns + потенциальный cache miss. Devirtualization (Go 1.19+) убирает это, если возможно.
Q24. Что такое //go:noinline и зачем?
Запрет inlining конкретной функции. Используется:
- В бенчмарках (чтобы measured function не была inlined).
- Для debugging (видеть в stack trace).
- Иногда для contrlling escape (inline может escape arguments).
Q25. Как сделать static binary с CGO?
CGO_ENABLED=1 \go build -ldflags '-extldflags "-static"' .Работает с musl (Alpine). С glibc — много проблем (DNS resolver и т.д.).
Альтернатива: CGO_ENABLED=0 если код позволяет.
6. Practice
Заголовок раздела «6. Practice»Задача 1. Inlining inspection
Заголовок раздела «Задача 1. Inlining inspection»Возьми функцию из любого open-source Go проекта (например, gin web framework). Прогон с -gcflags="-m=2". Найди:
- Какие функции inlined.
- Какие не inlined и почему.
- Какие переменные escape.
Задача 2. BCE optimization
Заголовок раздела «Задача 2. BCE optimization»Напиши функцию sum(s []int) int. Прогон с -gcflags="-d=ssa/check_bce/debug=1". Если есть BC — перепиши, чтобы убрать (через “hint” _ = s[len(s)-1]).
Задача 3. PGO experiment
Заголовок раздела «Задача 3. PGO experiment»Возьми любой web service. Сними CPU profile под нагрузкой (через go test -bench или wrk). Build с PGO. Сравни throughput.
Задача 4. Build tags для feature flags
Заголовок раздела «Задача 4. Build tags для feature flags»Реализуй модуль с двумя реализациями:
feature_basic.go(//go:build !premium)feature_premium.go(//go:build premium)
Покажи разный binary size с -tags="premium" и без.
Задача 5. CGO Hello World
Заголовок раздела «Задача 5. CGO Hello World»Напиши Go-программу, которая вызывает C-функцию через CGO. Замерь latency (time.Now() до и после CGO call) на 1M итераций. Объясни overhead.
Задача 6. cgo.Handle pattern
Заголовок раздела «Задача 6. cgo.Handle pattern»Напиши пример с C-callback’ом, который ожидает Go object. Используй cgo.NewHandle. Покажи, что без Handle Go-объект может быть собран GC.
Задача 7. Простой assembly function
Заголовок раздела «Задача 7. Простой assembly function»Напиши func Add(a, b int64) int64 в Plan 9 asm для amd64 (add_amd64.s). Замерь bench и сравни с Go-реализацией. (Спойлер: оба будут ~1 ns, потому что один такт.)
Задача 8. linkname в действии
Заголовок раздела «Задача 8. linkname в действии»Используй //go:linkname для доступа к runtime.nanotime. Сравни с time.Now().UnixNano(). Замерь overhead.
⚠️ С Go 1.23+ это может не сработать без специальных флагов. Может потребоваться //go:linkname runtime trampoline.
7. Источники
Заголовок раздела «7. Источники»src/cmd/compile/— исходники компилятора.src/cmd/compile/internal/ssa/— SSA passes (включая BCE, inlining, devirt).src/cmd/compile/internal/escape/— escape analysis.src/runtime/cgocall.go— CGO runtime support.src/runtime/asm_amd64.s— основной runtime assembly.- “Profile-guided optimization” — Go 1.21 release notes / Go blog.
- Keith Randall, “Go assembly cheat sheet” — wiki.
- Russ Cox, “Plan 9 from User Space: Assembler” — оригинальный design.
- “Cgo” — golang.org/cmd/cgo.
- Dave Cheney, “cgo is not Go” — blog post.
- “Go’s compiler intrinsics” — Vincent Blanchon, Medium 2020.
- “Go assembly by example” — github.com/teh-cmc/go-internals.
- Eli Bendersky, “Implementing FizzBuzz in Go assembly” — blog.
- “PGO in Go: real-world results” — Cloudflare blog 2024.
- “Replacing CGO with pure Go” — Tinkoff Engineering blog 2024.