Модуль: 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 стоимость одной аллокации складывается из нескольких частей:
- Сам акт выделения — попадание в size class, поиск свободного span в
mcache(per-P, без блокировок), при промахе — вmcentral(с lock), при промахе — вmheap. - GC mark/scan — каждый живой объект с указателями нужно просканировать на этапе mark. Чем больше объектов и указателей, тем дольше mark-фаза и тем чаще она запускается.
- GC assist — если горутина аллоцирует быстрее, чем GC успевает помечать, рантайм заставляет саму горутину выполнять работу маркировки (mutator assist). Это видно как «непонятные» паузы прямо в горячем коде.
- Триггер 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— boxingbyte/малого 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 arraya— источник трудноуловимых багов в конкурентном коде.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)vsmake([]T, 0, n)и full-slice expressiona[i:j:k]для контроля cap и предотвращения алиасинга. - Понимание, что
Builder.String()/Buffer.Bytes()отдают вид на внутренний буфер, и связанные lifetime-риски. - Способность спроектировать пул буферов с отсевом слишком больших объектов и корректным
Reset. - Знание изменения map на SwissTable (Go 1.24) и сохранения семантики size hint.