Модуль: Runtime и память · Уровень: Senior+

TL;DR#

Большинство «горячих» проблем производительности в Go — это не CPU, а аллокации: каждая аллокация в куче нагружает аллокатор и увеличивает работу GC (assist, mark, scan). Senior-разработчик умеет считать аллокации через -benchmem, предаллоцировать слайсы/мапы с учётом amortized growth, переиспользовать буферы (strings.Builder, bytes.Buffer, sync.Pool) и избегать неявного boxing в интерфейсах. Главное — измерять (pprof, -benchmem, escape analysis), а не угадывать.

Теория#

Стоимость аллокации и связь с GC#

В Go стоимость одной аллокации складывается из нескольких частей:

  1. Сам акт выделения — попадание в size class, поиск свободного span в mcache (per-P, без блокировок), при промахе — в mcentral (с lock), при промахе — в mheap.
  2. GC mark/scan — каждый живой объект с указателями нужно просканировать на этапе mark. Чем больше объектов и указателей, тем дольше mark-фаза и тем чаще она запускается.
  3. GC assist — если горутина аллоцирует быстрее, чем GC успевает помечать, рантайм заставляет саму горутину выполнять работу маркировки (mutator assist). Это видно как «непонятные» паузы прямо в горячем коде.
  4. Триггер GC — по умолчанию GOGC=100: GC стартует, когда heap вырос вдвое относительно live set после предыдущего цикла. Больше аллокаций ⇒ чаще GC.

Ключевая идея: уменьшение количества и размера аллокаций в куче снижает частоту GC и объём scan-работы. Стек — бесплатный (освобождается при возврате из функции), куча — нет.

// runtime.MemStats показывает кумулятивные аллокации
var m runtime.MemStats
runtime.ReadMemStats(&m)
fmt.Printf("Mallocs=%d Frees=%d HeapAlloc=%d NumGC=%d\n",
    m.Mallocs, m.Frees, m.HeapAlloc, m.NumGC)

Escape analysis: куда попадёт объект#

Компилятор решает, живёт ли объект на стеке или убегает (escape) в кучу. Если убегает — это аллокация.

go build -gcflags='-m -m' ./...
# Типичные сообщения:
#   ./x.go:10:6: moved to heap: x
#   ./x.go:12:13: ... argument does not escape
#   ./x.go:15:9: &T{...} escapes to heap

Частые причины escape:

  • возврат указателя на локальную переменную;
  • сохранение указателя в поле структуры/слайса, живущей дольше функции;
  • передача значения в interface{} (boxing);
  • размер заранее неизвестен компилятору (slice с динамической длиной, замыкания, захватывающие переменные);
  • вызовы через интерфейс, которые компилятор не может девиртуализировать.
func stackAlloc() int {
    x := 42   // остаётся на стеке
    return x
}

func heapAlloc() *int {
    x := 42   // moved to heap: возвращаем указатель
    return &x
}

Предаллокация слайсов: make с cap#

Рост слайса через append амортизированно O(1), но каждый рост — это новый backing array + копирование старых элементов. Если итоговый размер известен, задавайте cap.

// Плохо: несколько реаллокаций по мере роста
func bad(n int) []int {
    var s []int
    for i := 0; i < n; i++ {
        s = append(s, i) // реаллокации: 0->1->2->4->8->...
    }
    return s
}

// Хорошо: одна аллокация
func good(n int) []int {
    s := make([]int, 0, n) // len=0, cap=n
    for i := 0; i < n; i++ {
        s = append(s, i)
    }
    return s
}

Важно различать:

  • make([]int, n) — len=n, cap=n, элементы занулены, append будет добавлять после n.
  • make([]int, 0, n) — len=0, cap=n; правильный вариант для построения через append.

Типичная ошибка — make([]T, n) вместо make([]T, 0, n) с последующим append, что даёт двойной размер и «фантомные» нулевые элементы в начале.

Growth factor: 2x / 1.25x#

Стратегия роста слайса в современных версиях Go (Go 1.18+, runtime growslice):

Текущий capМножитель роста (приблизительно)
< 256 элементов×2
≥ 256 элементов×1.25 (плавный переход, growth = oldcap + (oldcap+3*256)/4)

Раньше (до 1.18) порог был «удвоение до 1024, потом ×1.25». Формула для больших слайсов сглажена, чтобы коэффициент мягко стремился к 1.25, а не прыгал.

После вычисления нужной ёмкости результат округляется вверх до размера size class аллокатора, поэтому реальный cap часто больше расчётного.

s := make([]int, 0)
prev := cap(s)
for i := 0; i < 2000; i++ {
    s = append(s, i)
    if cap(s) != prev {
        fmt.Printf("len=%d cap=%d\n", len(s), cap(s))
        prev = cap(s)
    }
}
// Видно скачки cap: 1,2,4,8,...,256,512,848,... (округлено по size class)

Амортизация: суммарное копирование при росте с 0 до N — это O(N), потому что геометрический ряд N + N/2 + N/4 + ... = 2N. Поэтому каждый отдельный append амортизированно O(1), но предаллокация устраняет эти 2N копирований и промежуточные аллокации.

Предаллокация мап: make с size hint#

make(map[K]V, hint) заранее выделяет достаточно бакетов, чтобы вместить hint элементов без рехеширования.

m := make(map[string]int, 1000) // избегаем серии grow/rehash

Под капотом (классическая реализация до swissmap в Go 1.24): мапа — это массив бакетов по 8 слотов. При превышении load factor (~6.5 на бакет) происходит incremental growth: выделяется вдвое больше бакетов, и элементы постепенно эвакуируются. Hint позволяет сразу выбрать нужное число бакетов. В Go 1.24+ map реализована на SwissTable, но семантика size hint сохранилась — она по-прежнему снижает число grow-операций.

Замечание: hint — это подсказка, а не жёсткая ёмкость. Мапа всё равно вырастет при необходимости.

sync.Pool: устройство и жизненный цикл#

sync.Pool — пул временных объектов для переиспользования между горутинами, чтобы снизить число аллокаций и нагрузку на GC.

var bufPool = sync.Pool{
    New: func() any { return new(bytes.Buffer) },
}

func handle(data []byte) {
    buf := bufPool.Get().(*bytes.Buffer)
    buf.Reset()              // ОБЯЗАТЕЛЬНО: объект из пула «грязный»
    defer bufPool.Put(buf)
    buf.Write(data)
    // ... используем buf
}

Под капотом:

  • Per-P pools. У каждого логического процессора P (их GOMAXPROCS) есть приватная ячейка (private) и сдвиговый дек (shared). Get/Put сначала работают с локальным P без блокировок — это и даёт масштабируемость. Только при промахе горутина «крадёт» (work-stealing) объекты из shared-деков других P.
  • Pin к P. На время Get/Put горутина пиннится к P (runtime_procPin), чтобы избежать гонок при миграции.
  • Очистка при GC. В начале каждого GC рантайм вызывает poolCleanup. Pool не удерживает объекты от сборки между циклами GC. Это сделано специально, чтобы пул не превращался в источник утечки.
  • Victim cache (Go 1.13+). Чтобы один GC не обнулял весь пул резко (что вызывало всплеск аллокаций сразу после GC), введён двухуровневый механизм:
    • При очистке текущие объекты не выбрасываются сразу, а перемещаются в victim (жертвенный) кэш.
    • Get при промахе в основном кэше пробует достать из victim.
    • На следующем GC victim очищается окончательно, а текущие объекты снова переезжают в victim.
    • Итог: объект живёт до двух циклов GC, что сглаживает провалы hit-rate.
GC #1: live -> victim,   New victim primary = old primary
GC #2: victim -> GC,     live -> victim

Когда sync.Pool НЕ помогает (или вредит):

  • Объекты разного размера. Пул отдаёт случайный объект; если вы кладёте слайсы разной capacity, можно получить маленький буфер и снова реаллоцировать его, либо удерживать огромные буферы (нужна стратегия отсева больших объектов).
  • Долгоживущие объекты. Pool предназначен для короткоживущих временных объектов в рамках запроса/операции. Для кэширования с контролируемым TTL он не годится — GC сбросит его непредсказуемо.
  • Низкая частота переиспользования. Если объект берётся редко, GC успеет очистить пул между использованиями и New будет вызываться всё равно.
  • Маленькие объекты. Накладные расходы Get/Put (pin, type assertion) могут превысить выгоду от устранения дешёвой аллокации. Для крошечных структур stack allocation быстрее.
  • Хранение объектов с указателями наружу. Если вы кладёте в пул структуру, ссылающуюся на большой граф, вы продлеваете его жизнь.

Классическое правило: обнуляйте/Reset объект перед Put или после Get — иначе утечёт старое содержимое (security-риск) или будут баги.

strings.Builder и bytes.Buffer#

Конкатенация строк через + или += в цикле — антипаттерн: строки иммутабельны, каждая операция аллоцирует новую строку.

// Плохо: O(n^2) аллокаций и копирований
func concatBad(parts []string) string {
    s := ""
    for _, p := range parts {
        s += p
    }
    return s
}

// Хорошо: амортизированный рост, одна финальная строка
func concatGood(parts []string) string {
    var b strings.Builder
    n := 0
    for _, p := range parts {
        n += len(p)
    }
    b.Grow(n) // предаллокация — устраняем все реаллокации
    for _, p := range parts {
        b.WriteString(p)
    }
    return b.String() // String() не копирует backing array
}

strings.Builder.String() использует unsafe, чтобы вернуть строку без копирования backing-байтов — это его главное преимущество перед bytes.Buffer.String() (который копирует). Builder запрещает копирование себя (noCopy) после первого использования.

Reuse bytes.Buffer через пул — типовой паттерн (см. sync.Pool выше). Не забывайте Reset().

var b bytes.Buffer
for _, job := range jobs {
    b.Reset()        // переиспользуем backing array
    encode(&b, job)
    send(b.Bytes())  // ВНИМАНИЕ: Bytes() возвращает срез на внутренний буфер
}

Boxing в interface#

Помещение значения в интерфейс (interface{}/any) часто вызывает аллокацию: интерфейс хранит (type, pointer), и для не-указательного значения нужно где-то разместить данные.

func box(x int) any { return x } // int escapes to heap (boxing)

Исключения/оптимизации:

  • Рантайм кэширует малые целые (0–255): staticuint64s — boxing byte/малого int не аллоцирует.
  • Помещение указателя в интерфейс не аллоцирует (указатель и есть данные).
  • fmt.Println(args ...any) боксит каждый аргумент — горячий путь логирования может удивить аллокациями.
// Плохо в горячем пути: каждый x боксится
var sink []any
for i := 0; i < n; i++ {
    sink = append(sink, i) // n boxing-аллокаций
}

Value vs pointer receivers и аллокации#

Выбор receiver влияет и на копирование, и на escape:

  • Value receiver копирует значение при каждом вызове. Для больших структур это дорого по CPU, но не аллоцирует (копия на стеке).
  • Pointer receiver не копирует, но если значение нужно передать через интерфейс — оно escape в кучу (интерфейс должен хранить указатель на живущий объект).
type Big struct{ data [1024]byte }

func (b Big) ValSum() int { /* копия 1KB на стеке */ }
func (b *Big) PtrSum() int { /* без копии */ }

var x Big
var i interface{ ValSum() int } = x  // КОПИЯ x уезжает в кучу (boxing значения)
var j interface{ PtrSum() int } = &x // &x escape, сам x может остаться

Правила для senior:

  • Консистентность набора методов (не мешать value/pointer без причины).
  • Большие структуры — pointer receiver (избежать копий).
  • Если метод мутирует — pointer receiver обязателен.
  • Помните: присваивание значения в интерфейс копирует/боксит; pointer receiver + interface ⇒ escape объекта.

append pitfalls и аллокации#

// 1. Алиасинг backing array
a := make([]int, 3, 5)
b := append(a, 99)  // cap хватает -> b делит backing с a!
b[0] = -1           // a[0] тоже стал -1

// 2. Скрытая аллокация при превышении cap
c := append(a, 1, 2, 3) // cap не хватает -> новый массив, c независим от a

// 3. append(nil-slice...) — корректно, аллоцирует при первом элементе
var d []int
d = append(d, 1) // ок

Гарантированно скопировать (отвязать от backing):

dst := make([]int, len(src))
copy(dst, src)
// или (Go 1.21+):
dst := slices.Clone(src)

Подводные камни / gotchas#

  • make([]T, n) + append вместо make([]T, 0, n) — двойной размер и нулевые «хвосты» в начале.
  • b := append(a, x) при достаточном cap молча мутирует backing array a — источник трудноуловимых багов в конкурентном коде.
  • sync.Pool не зануляет объекты — забытый Reset() ведёт к утечке данных между запросами.
  • sync.Pool нельзя использовать как кэш с гарантиями: GC очистит его в любой момент.
  • bytes.Buffer.Bytes() и strings.Builder через unsafe возвращают срез/строку на внутренний буфер — нельзя удерживать после Reset()/повторного использования.
  • Boxing в any в горячем цикле (логи, метрики) — невидимый источник аллокаций; ловится только -benchmem/escape analysis.
  • Предаллокация по слишком большому cap тратит память зря и может удерживать большой backing через под-слайс.
  • Value receiver на большой структуре в горячем пути — тихий CPU-killer (копирование), но не аллокация; pointer receiver + interface — наоборот, аллокация.
  • b.Grow(n) у Builder/Buffer надо звать ДО записи, иначе реаллокации уже случились.

Вопросы на собеседовании#

В: Чем make([]int, 0, n) отличается от make([]int, n) и почему это важно при построении слайса через append? О: Первый создаёт слайс len=0, cap=n — append будет заполнять заранее выделенный backing без реаллокаций до n элементов. Второй создаёт len=n, cap=n из n занулённых элементов, и append начнёт добавлять после них, давая фактический размер 2n и нулевой «хвост» в начале. При построении через append правильный вариант — make([]T, 0, n): одна аллокация и корректный результат.

В: Как работает рост слайса и какой growth factor в современном Go? О: При нехватке cap append вызывает growslice: вычисляется новая ёмкость (~×2 пока cap < 256, и плавно ×1.25 для больших), результат округляется вверх до size class аллокатора, выделяется новый массив и копируются старые элементы. Амортизированно append — O(1), потому что суммарное копирование с 0 до N равно ~2N (геометрический ряд). Предаллокация устраняет промежуточные аллокации и копирования.

В: Объясните устройство sync.Pool: per-P пулы и victim cache. О: У каждого P есть локальная приватная ячейка и shared-дек; Get/Put сначала работают с локальным P без блокировок, при промахе крадут из shared других P. В начале каждого GC poolCleanup очищает пул, но не сразу: текущие объекты переезжают в victim cache, который Get проверяет при промахе. На следующем GC victim очищается окончательно. Так объект живёт до двух циклов GC — это сглаживает всплеск аллокаций сразу после сборки. Pool намеренно не удерживает объекты надолго, чтобы не становиться источником утечки.

В: Когда sync.Pool НЕ даёт выигрыша или вредит? О: Для долгоживущих объектов (GC сбросит непредсказуемо — это не кэш), при низкой частоте переиспользования (GC успевает очистить, New вызывается всё равно), для очень маленьких/дешёвых объектов (overhead на pin и type assertion перекрывает выгоду), при объектах разной capacity (можно получить маленький буфер и реаллоцировать, либо удерживать большие). Также опасен без Reset — утечка данных между запросами.

В: Почему конкатенация строк через += в цикле — антипаттерн, и чем strings.Builder лучше bytes.Buffer? О: Строки иммутабельны, += каждый раз аллоцирует новую строку и копирует — O(n²). strings.Builder амортизированно растит внутренний []byte и возвращает строку без копирования (через unsafe), тогда как bytes.Buffer.String() копирует backing. Идеально — b.Grow(totalLen) заранее, чтобы избежать всех реаллокаций.

В: Что такое boxing в интерфейс и когда он не аллоцирует? О: Интерфейс хранит пару (тип, указатель на данные); чтобы поместить не-указательное значение, рантайму нужно разместить данные — обычно в куче (escape). Не аллоцирует: помещение указателя (он сам и есть данные) и малые целые 0–255 (кэш staticuint64s). Горячие места — fmt-вызовы и логирование с ...any.

В: Как value vs pointer receiver влияет на аллокации? О: Value receiver копирует значение (на стеке, без аллокации, но дорого по CPU для больших структур). При присваивании значения в интерфейс копия боксится и уезжает в кучу. Pointer receiver не копирует, но &x в интерфейсе вызывает escape объекта. Правило: большие/мутирующие — pointer; помнить, что интерфейс + значение = boxing с аллокацией.

В: В чём опасность append, делящего backing array? О: Если в исходном слайсе хватает cap, append пишет в тот же backing, и новый слайс делит память со старым — мутация одного видна в другом. В конкурентном коде или при передаче под-слайсов это даёт трудноуловимые баги. Решение — slices.Clone или явный make+copy, либо full-slice expression a[low:high:max], ограничивающий cap.

В: Как измерить аллокации в бенчмарке? О: go test -bench=. -benchmem выводит B/op (байт на операцию) и allocs/op (число аллокаций). Дополнительно — escape analysis (-gcflags='-m'), runtime.ReadMemStats, pprof heap-профиль. Senior смотрит именно на allocs/op: ноль аллокаций в горячем пути — частая цель оптимизации.

func BenchmarkConcat(b *testing.B) {
    parts := []string{"a", "b", "c", "d"}
    b.ReportAllocs()
    b.ResetTimer()
    for i := 0; i < b.N; i++ {
        var sb strings.Builder
        for _, p := range parts {
            sb.WriteString(p)
        }
        _ = sb.String()
    }
}
go test -bench=BenchmarkConcat -benchmem
# BenchmarkConcat-10   10000000   45 ns/op   8 B/op   1 allocs/op

На что копают на senior+#

  • Умение читать вывод -gcflags='-m -m' и объяснить, почему конкретная переменная escape (возврат указателя, boxing, неизвестный размер, замыкание).
  • Понимание связи аллокаций с GC: assist, частота циклов через GOGC/GOMEMLIMIT, влияние числа указателей на scan-фазу.
  • Точное устройство sync.Pool: per-P shared-деки, work stealing, pin к P, victim cache, и почему Pool не годится для кэширования.
  • Знание формулы роста слайса и того, что cap округляется по size class; умение посчитать амортизацию.
  • Различие make([]T, n) vs make([]T, 0, n) и full-slice expression a[i:j:k] для контроля cap и предотвращения алиасинга.
  • Понимание, что Builder.String()/Buffer.Bytes() отдают вид на внутренний буфер, и связанные lifetime-риски.
  • Способность спроектировать пул буферов с отсевом слишком больших объектов и корректным Reset.
  • Знание изменения map на SwissTable (Go 1.24) и сохранения семантики size hint.