Суть Читай реальные Go-сниппеты и строку gctrace, предсказывай поведение GC и выбирай фикс с наибольшим рычагом.
Высота — путь к senior
НольJuniorMiddleSenior
Ты на senior-высоте — в орбите
◷ 14 min
Профиль аллокаций и лог GC — это место, где GC-проблемы действительно диагностируются. Прочитай код и трейс, затем выбери фикс, который senior-инженер сделает первым.
Цель
Отработай цикл, который ты запускаешь в каждом GC-инциденте: прочитай горячий путь, предскажи, откуда мусор, и потянись за фиксом с наибольшим рычагом раньше, чем тронешь хоть одну ручку тюнинга.
Сниппет 1 — растущий срез
func collect(rows []Row) []byte { var out []byte // nil-срез, нулевая ёмкость for _, r := range rows { line := fmt.Sprintf("%d,%s\n", r.ID, r.Name) out = append(out, line...) // многократно растёт + копирует } return out}
Викторина
Completed
На 100k строк откуда берётся бо́льшая часть мусора и какой единственный фикс с наибольшим рычагом?
Heads-up `rows` передаются снаружи, а не аллоцируются здесь. Мусор создаётся внутри цикла — строки Sprintf и многократно растущий backing-массив `out`.
Heads-up append переиспользует ёмкость, лишь пока она есть; срез с нулевой ёмкостью переаллоцируется (обычно удваиваясь) много раз на 100k строк, каждый раз всё копируя.
Heads-up GOGC меняет, когда запускается GC, а не сколько аллоцирует цикл. Фикс — аллоцировать меньше: преаллоцировать буфер и убрать Sprintf.
Сниппет 2 — пул
var bufPool = sync.Pool{New: func() any { return new(bytes.Buffer) }}func render(w io.Writer, v *View) error { b := bufPool.Get().(*bytes.Buffer) defer bufPool.Put(b) // ... много пишем в b ... _, err := w.Write(b.Bytes()) return err}
Викторина
Completed
В этом коде с пулом буферов есть баг корректности, который нагрузочный тест рано или поздно вскроет. Какой?
Heads-up sync.Pool явно безопасен для конкурентных Get/Put между горутинами. Баг — в гигиене: буфер не сброшен (Reset) перед переиспользованием.
Heads-up defer выполняется при возврате из функции, после завершения w.Write — порядок в норме. Дефект — отсутствующий Reset.
Heads-up Функция New вызывается, только когда пул пуст, и GC может дренировать sync.Pool между циклами. Утечки нет — проблема в устаревшем состоянии при переиспользовании.
Сниппет 3 — строка gctrace
gc 488 @62.1s 39%: 0.41+352+1.1 ms clock, ... 1900->2980->1490 MB, 1990 MB goal, 8 P
Викторина
Completed
Читая эту единственную строку gctrace, какое утверждение верно?
Heads-up STW-фазы крошечные и здоровые. Патология — 352 мс конкурентной маркировки и 39% GC CPU — коллектор не успевает за аллокациями.
Heads-up Завершение ниже цели — нормально после sweep; тревога в том, что время маркировки и GC CPU взрываются, то есть pacer проигрывает гонку.
Heads-up Поле X% — это доля CPU-времени в GC с момента старта программы, а не частота пауз на запрос.
Сниппет 4 — escape-анализ
func newPoint(x, y int) *Point { // возвращает указатель... p := Point{x, y} return &p // ...поэтому p убегает в кучу}func sumLocal(x, y int) int { p := Point{x, y} // никогда не убегает return p.X + p.Y // остаётся на стеке}
Викторина
Completed
Какой вызов аллоцирует в куче (добавляя работу GC) и каково общее правило?
Heads-up Значения на стеке не стоят работы GC. Point в sumLocal никогда не убегает, поэтому escape-анализ держит его на стеке.
Heads-up Арифметика над стековыми значениями ничего не аллоцирует. Аллокацию в куче форсирует escape (ссылка переживает фрейм), а не вычисление.
Heads-up Go использует стек, только когда escape-анализ доказывает, что значение не убегает. Возврат &p форсирует Point из newPoint в кучу.
Итог
Каждый GC-инцидент читается в коде и трейсах: срезы с нулевой ёмкостью и Sprintf на каждой итерации — классические горячие точки аллокаций; объекты из пула нужно сбрасывать (Reset) перед переиспользованием; строка gctrace с одного взгляда показывает долю GC CPU и время маркировки; а escape-анализ решает стек-vs-куча по тому, переживает ли ссылка свой фрейм. Диагностируй по профилю, чини аллокацию, потом перепрофилируй для подтверждения.