Перейти к содержимому

Zero-Allocation Patterns в Go

Зачем знать: Каждая аллокация — это работа для garbage collector. На hot path с высоким QPS аллокации становятся доминирующим фактором latency: GC pauses, cache misses, contention на runtime allocator. Zero-allocation patterns — это набор техник для написания кода, который не давит на GC. Знание escape analysis, sync.Pool, strings.Builder, unsafe-конверсий и struct alignment отличает инженера, способного писать low-latency сервисы (HFT, ad bidding, real-time gaming), от инженера, который только “пишет на Go”.


  1. Базовая концепция (повторение)
  2. Production-практики
  3. Gotchas (10+)
  4. Реальные кейсы
  5. Вопросы (25)
  6. Practice (5-8)
  7. Источники

В Go каждая heap-аллокация:

  1. Требует работы аллокатора (mcache, mcentral, mheap)
  2. Увеличивает working set памяти
  3. Создаёт работу для GC (mark phase scans pointers)
  4. Может вызвать GC раньше (heap goal triggered)
  5. Может попасть в STW pause (~100μs на современных версиях Go)

При высоком QPS (10K+ rps) даже 5 аллокаций на запрос = 50K alloc/sec = существенный GC pressure.

Hot paths в high-throughput сервисах:

  • HTTP/gRPC request handlers
  • Парсеры протоколов (JSON, Protobuf, binary)
  • Сериализаторы
  • Hot loops в batch processing

Low-latency требования:

  • Financial / HFT (microsecond-level)
  • Real-time gaming
  • Ad bidding (real-time bidding, <100ms total)
  • Database engines

High-throughput servers:

  • Load balancers (Cloudflare, Caddy)
  • API gateways
  • Message brokers

Cold paths: rarely-called функции (init, config loading) ❌ Premature optimization без бенчмарков ❌ Code clarity vs performance — если zero-alloc делает код unmaintainable, лучше profiler-driven optimization потом ❌ Когда работа dominated I/O — оптимизация аллокаций на сетевом коде не даст эффекта если узкое место — это disk/network

Правило Knuth, актуальное в 2026: “Premature optimization is the root of all evil.” Сначала меряем, потом оптимизируем.

Go компилятор делает escape analysis — определяет, может ли переменная “сбежать” из стека (escape to heap).

func stack() {
x := 42 // на стеке
_ = x
}
func heap() *int {
x := 42 // на heap (escapes — возвращаем pointer)
return &x
}

Проверить через флаг компилятора:

Окно терминала
go build -gcflags="-m" main.go

Output:

./main.go:5:6: x does not escape
./main.go:9:6: moved to heap: x

func BenchmarkParse(b *testing.B) {
data := []byte(`{"foo":"bar"}`)
b.ResetTimer()
for i := 0; i < b.N; i++ {
var v map[string]string
json.Unmarshal(data, &v)
}
}
Окно терминала
go test -bench=. -benchmem
BenchmarkParse-8 500000 3200 ns/op 320 B/op 8 allocs/op

B/op = байт на операцию. allocs/op = количество аллокаций.

⚠️ Цель: 0 allocs/op для hot path.

Окно терминала
go tool pprof -alloc_space http://localhost:6060/debug/pprof/heap
(pprof) top

Показывает функции, которые в сумме аллоцировали больше всего памяти.

Окно терминала
(pprof) list myHotFunc

Показывает строки с allocations.

Окно терминала
go build -gcflags="-m" ./...
go build -gcflags="-m=2" ./... # более verbose

Output:

./parser.go:42:13: ([]byte)(s) escapes to heap
./parser.go:45:15: result escapes to heap
./parser.go:50:6: &buf escapes to heap

Go runtime проверяет slice[i] и slice[i:j] на out-of-bounds. Иногда эти проверки можно убрать:

Окно терминала
go build -gcflags="-d=ssa/check_bce/debug=1" ./...

Output показывает строки, где BCE сработал и где нет:

./code.go:10:5: Found IsInBounds
./code.go:15:8: Found IsSliceInBounds

Если на критической line BCE не сработал — можно reorganize код.

❌ Плохо:

var result []Item
for _, x := range input {
result = append(result, process(x)) // growing slice — re-allocations
}

✅ Хорошо:

result := make([]Item, 0, len(input))
for _, x := range input {
result = append(result, process(x))
}

Аналогично для maps:

m := make(map[string]int, expectedSize)

Эффект: избегает re-allocation при росте. На больших slice/map — в разы меньше аллокаций.

var bufPool = sync.Pool{
New: func() any {
return new(bytes.Buffer)
},
}
func process(input string) string {
buf := bufPool.Get().(*bytes.Buffer)
defer func() {
buf.Reset()
bufPool.Put(buf)
}()
buf.WriteString("prefix:")
buf.WriteString(input)
return buf.String()
}

Когда использовать sync.Pool:

  • Объекты создаются часто
  • Используются короткое время
  • Размер объектов значимый (>1KB)
  • Можно reset перед reuse

⚠️ Gotcha: sync.Pool может потерять объекты на GC. Это by design — не использовать как cache.

❌ Плохо:

result := ""
for _, s := range parts {
result += s // каждое + аллоцирует новую строку
}

✅ Хорошо:

var b strings.Builder
b.Grow(estimatedSize) // pre-allocate
for _, s := range parts {
b.WriteString(s)
}
result := b.String()

❌ Плохо:

data, _ := io.ReadAll(r) // аллоцирует весь buffer
fmt.Println(string(data))

✅ Хорошо (при streaming):

io.Copy(os.Stdout, r) // без буферизации в памяти

Конверсия []bytestring обычно копирует данные (потому что строки immutable).

// Standard — копирует
s := string(byteSlice)
b := []byte(s)

С Go 1.20+ можно делать zero-copy через unsafe:

import "unsafe"
// []byte → string (без копии)
func b2s(b []byte) string {
return unsafe.String(unsafe.SliceData(b), len(b))
}
// string → []byte (без копии)
func s2b(s string) []byte {
return unsafe.Slice(unsafe.StringData(s), len(s))
}

⚠️ CRITICAL gotcha: результирующая string/slice разделяет память с исходными данными.

  • Изменение []byte после b2s → undefined behavior (strings должны быть immutable)
  • Изменение результата s2b → undefined behavior (память string’ов в read-only segment может быть)

Использовать только когда уверены в lifetime данных.

В Go interface значение всегда обёрнуто в (type, data) пару. Передача scalar через interface → heap allocation.

❌ Плохо:

func log(values ...any) { // any = interface{}
// ...
}
log(42) // 42 → boxed in interface → heap allocation

✅ Лучше (если known type):

func logInt(value int) {
// ...
}

Или type-specialized методы:

func (l *Logger) Int(key string, val int) *Logger
func (l *Logger) String(key string, val string) *Logger

(Zap и Zerolog так делают.)

// Closure аллоцирует, если capture'ит переменные
for i := 0; i < N; i++ {
func() {
fmt.Println(i) // i captured by reference
}()
}

Лучше передавать аргументы:

for i := 0; i < N; i++ {
func(i int) {
fmt.Println(i)
}(i)
}

Но даже эта function literal может escape — лучше extract named function.

Range по map создаёт iterator state (allocates).

❌ Hot loop:

for k, v := range m {
// ...
}

✅ Если можно — заменить на slice:

type kv struct { K string; V int }
list := make([]kv, 0, len(m))
for k, v := range m {
list = append(list, kv{k, v}) // один раз
}
// потом hot iterating:
for _, item := range list {
// ...
}

Это полезно если map итерируется много раз без изменений.

type Point struct { X, Y float64 }
func dist(p Point) float64 { ... } // by value — на стеке
func dist(p *Point) float64 { ... } // by pointer — pointer на стеке, но *Point может escape

Маленькие структуры (≤2 words) — по value эффективнее. Но если struct большая (>3 words) — pointer лучше (хотя возможно escape).

⚠️ Padding: struct с плохо упорядоченными полями может занимать больше места:

type Bad struct {
a bool // 1 byte + 7 padding
b int64 // 8 bytes
c bool // 1 byte + 7 padding
}
// Размер: 24 bytes
type Good struct {
b int64 // 8 bytes
a bool // 1 byte
c bool // 1 byte + 6 padding
}
// Размер: 16 bytes
var data [16]byte // на стеке гарантированно
var data = make([]byte, 16) // может escape

Array — value type, slice — reference (с headers).

sync.Pool — стандартный inst:

var bufferPool = sync.Pool{
New: func() any {
return &bytes.Buffer{}
},
}
func handler() {
buf := bufferPool.Get().(*bytes.Buffer)
defer func() {
buf.Reset()
bufferPool.Put(buf)
}()
// use buf
}

Custom pool для специфики:

type Job struct {
ID uint64
Data []byte
next *Job // для linked list pool
}
type JobPool struct {
mu sync.Mutex
head *Job
}
func (p *JobPool) Get() *Job {
p.mu.Lock()
defer p.mu.Unlock()
if p.head == nil {
return &Job{}
}
j := p.head
p.head = j.next
j.next = nil
return j
}
func (p *JobPool) Put(j *Job) {
j.ID = 0
j.Data = j.Data[:0]
p.mu.Lock()
j.next = p.head
p.head = j
p.mu.Unlock()
}

Custom pool сложнее, но не подвержен GC eviction (sync.Pool периодически чистится).

runtime.Arena был экспериментальным в Go 1.20, но abandoned — proposal отозван из-за сложности и опасности use-after-free.

Альтернативы:

  • Manual arena: большой []byte buffer, ручной offset bumping
  • Per-request arena: аллоцировать на старте request, освобождать в конце
type Arena struct {
buf []byte
}
func (a *Arena) Alloc(size int) []byte {
if len(a.buf)+size > cap(a.buf) {
// не растим — fallback на heap
return make([]byte, size)
}
old := len(a.buf)
a.buf = a.buf[:old+size]
return a.buf[old : old+size]
}
func (a *Arena) Reset() {
a.buf = a.buf[:0]
}

⚠️ Не использовать как замену GC — это для very specific patterns.

Для одного типа объектов. Pre-allocate N штук, выдавать из массива.

type Slab[T any] struct {
items []T
free []int // индексы свободных
}
func (s *Slab[T]) Acquire() *T {
if len(s.free) == 0 {
s.items = append(s.items, *new(T))
return &s.items[len(s.items)-1]
}
idx := s.free[len(s.free)-1]
s.free = s.free[:len(s.free)-1]
return &s.items[idx]
}
func (s *Slab[T]) Release(item *T) {
// вычислить индекс и добавить в free
}

Slab pattern полезен когда:

  • Тип фиксирован
  • Объекты живут долго (cache, connection pool)
  • Нужен предсказуемый footprint

Если переменная не escape — она на стеке (бесплатно). Триггеры escape:

  • Возврат pointer’а
  • Передача interface (boxing)
  • Запись в map/slice через pointer
  • Goroutine с captured variable
  • unsafe operations

Минимизировать escapes:

// Escapes
func newInt() *int {
x := 42
return &x // escapes
}
// Не escapes
func sumWithCallback(cb func(int)) {
x := 42 // на стеке, передаём по value
cb(x)
}

Pattern “callback to consume” часто помогает не escape:

// Вместо возврата []byte:
func ReadInto(buf []byte) int // caller предоставляет buffer

unsafe.Sizeof показывает реальный размер struct (с padding):

type S struct {
A bool
B int64
C bool
}
fmt.Println(unsafe.Sizeof(S{})) // 24 на 64-bit

Правило: сортировать поля от больших к маленьким (по размеру).

В golang.org/x/tools/go/analysis/passes/fieldalignment:

Окно терминала
go install golang.org/x/tools/go/analysis/passes/fieldalignment/cmd/fieldalignment@latest
fieldalignment ./...

Output:

./model.go:10:6: struct of size 24 could be 16

Auto-fix:

Окно терминала
fieldalignment -fix ./...

Когда важно:

  • Структуры, которые аллоцируются миллионами штук
  • Cache-line concerns (avoid false sharing)
  • Network protocols (packed structs)

⚠️ Не оптимизировать преждевременно — для большинства типов 8 байт лишних не критичны.

Старый инструмент Dave Cheney для визуализации GC. Запускает app, парсит gctrace=1 output.

В 2026 году заменяется runtime/metrics + Prometheus + Grafana.

import "runtime/debug"
debug.WriteHeapDump(file.Fd())

Дамп в формате, который можно анализировать через external tools. Используется редко, обычно достаточно pprof.

Tool для analyzing core dumps (kernel coredump или heap dump). Установка:

Окно терминала
go install golang.org/x/debug/cmd/viewcore@latest

Использование:

Окно терминала
viewcore corefile
> goroutines
> heap

Полезно для post-mortem analysis (crash dump анализ).

Стандартный encoding/json копирует входные данные. Альтернативы:

Generator code. Создаёт MarshalJSON/UnmarshalJSON для конкретного типа.

//easyjson:json
type User struct {
ID int `json:"id"`
Name string `json:"name"`
}
Окно терминала
easyjson -all user.go

Generated код использует jlexer + jwriter — почти zero-alloc.

JIT-based JSON парсер. Использует SIMD на amd64.

import "github.com/bytedance/sonic"
sonic.Unmarshal(data, &v)

Бенчмарки показывают 2-3x ускорение vs encoding/json и меньше аллокаций.

Drop-in replacement для encoding/json:

import jsoniter "github.com/json-iterator/go"
var json = jsoniter.ConfigCompatibleWithStandardLibrary
json.Unmarshal(data, &v)

Чуть быстрее encoding/json, не радикально.

Libraryns/opB/opallocs/op
encoding/json4500120028
jsoniter320080014
easyjson18002002
sonic1100801

(Цифры приблизительные, зависят от схемы.)

// Allocating version
func ConcatBad(parts []string) string {
s := ""
for _, p := range parts {
s += p
}
return s
}
// Zero-alloc version
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()
}
func BenchmarkConcatBad(b *testing.B) {
parts := []string{"a", "b", "c", "d", "e"}
for i := 0; i < b.N; i++ {
ConcatBad(parts)
}
}
func BenchmarkConcatGood(b *testing.B) {
parts := []string{"a", "b", "c", "d", "e"}
for i := 0; i < b.N; i++ {
ConcatGood(parts)
}
}

Результат (Apple M1):

BenchmarkConcatBad-8 10000000 110 ns/op 32 B/op 4 allocs/op
BenchmarkConcatGood-8 30000000 40 ns/op 8 B/op 1 allocs/op

3x быстрее, 4x меньше аллокаций.


Объекты в sync.Pool могут быть очищены при любом GC. Поэтому:

  • ❌ Не использовать как cache (объекты пропадут)
  • ✅ Использовать как “free list” для часто-аллоцируемых объектов
  • ⚠️ В Go 1.13+ pool has 2-cycle GC behavior — большинство объектов выживает 1 GC
b := []byte("hello")
s := unsafe.String(&b[0], len(b))
b[0] = 'H' // UNDEFINED BEHAVIOR — изменили "immutable" string!

Никогда не модифицировать []byte после конверсии в string через unsafe.

var x int = 42
var i any = x // x boxed → escape!

Передача scalar в any параметр всегда аллоцирует. Это причина почему logging libraries (zap, zerolog) используют typed methods.

func makeAdder(x int) func(int) int {
return func(y int) int { // x captured → x на heap
return x + y
}
}

x теперь на heap, потому что closure пережил scope makeAdder.

s := make([]int, 0, 10)
s = append(s, 1, 2, 3) // в существующий cap, no alloc
s = append(s, ...11 more...) // exceeds cap → re-alloc

Если знаете финальный размер — pre-allocate с правильным cap.

b := []byte{...}
s := string(b) // КОПИЯ

Исключение: в case m[string(byteKey)] компилятор знает, что временная string не сохранится → optimization не копирует. Но s := string(b); m[s] будет копировать.

Симметрично:

s := "hello"
b := []byte(s) // КОПИЯ

Это нужно потому что strings immutable. Для zero-copy — unsafe.Slice (с осторожностью).

3.8. ⚠️ Maps аллоцируют при превышении load factor

Заголовок раздела «3.8. ⚠️ Maps аллоцируют при превышении load factor»

Внутри map при росте идёт rehashing (mapassign_grow). Это дискретная аллокация — иногда тратится много.

Pre-allocate с правильным hint:

m := make(map[string]int, expectedSize)

В Go < 1.14 каждый defer аллоцировал closure. В Go 1.14+ есть “open-coded defer” — для most cases zero-alloc. Но:

  • defer в цикле = многократные defer = можно превысить лимит
  • defer с >8 параметрами падает на slow path

Best practice: только один defer на функцию, минимум аргументов.

s := fmt.Sprintf("user=%d action=%s", uid, action) // ~5 alloc

Для hot paths: использовать strconv.AppendInt, strconv.AppendQuote на собственный buffer.

buf := bufPool.Get().([]byte)[:0]
buf = strconv.AppendInt(buf, int64(uid), 10)
buf = append(buf, " action="...)
buf = append(buf, action...)

Каждая goroutine = минимум 2KB stack (Go 1.4+). 1M goroutines = 2GB только на stacks (+ runtime overhead).

Если pool из миллиона short-lived goroutines — это вызывает значимый footprint.

ch := make(chan int, 1000000) // 4MB+ alloc

Большие буферизованные channels могут быть скрытым источником памяти.

reflect.Value, reflect.TypeOf создают объекты на heap. Reflect-heavy code (e.g., старый encoding/json) — много аллокаций.

Хорошие новости: time.Time — value type, не аллоцирует. Можно использовать в hot path без worry (но monotonic clock иногда escape’ит — реже).

Каждый вызов errors.New("msg") аллоцирует. В hot path лучше pre-create sentinel errors:

var ErrNotFound = errors.New("not found")

unsafe.Sizeof — compile-time, точный размер. Но не учитывает heap-allocated parts (slice data, map internals). Реальная memory footprint больше.


Cloudflare писали о zero-alloc logging:

  • Standard log package аллоцирует ~10 раз на line
  • Их кастомный logger (под лицензией) использует sync.Pool + typed methods
  • Результат: 0 alloc/log, throughput 1M+ logs/sec

Подход:

e := logger.NewEntry() // из pool
e.Str("user", userName)
e.Int("uid", uid)
e.Msg("login")
// e released back to pool

go.uber.org/zap — production logger, zero-alloc для structured logs.

Подход:

  • Field структуры (typed: zap.Int, zap.String)
  • Custom encoder pool
  • Logger.Check(level, msg) возвращает nil если level disabled — exit перед формированием fields

Бенчмарк zap vs logrus:

  • zap structured: ~600 ns/op, 1 alloc/op
  • logrus structured: ~13000 ns/op, 64 alloc/op

ByteDance (TikTok) разработал sonic потому что:

  • TikTok backend на Go обрабатывает миллиарды JSON в сутки
  • encoding/json была bottleneck’ом
  • sonic — JIT + SIMD = 2-3x speedup, 10x меньше аллокаций
  • Сейчас open source, используется во многих ByteDance сервисах

Aerospike Go client — пример zero-alloc paranoia:

  • Все request/response объекты в sync.Pool
  • Buffer pools для serialization
  • Custom field alignment
  • Результат: latency p99 <1ms на ops, минимальная GC pressure

Discord (на Go) обрабатывали миллионы сообщений в секунду:

  • Hot path читает channel messages из Redis
  • Standard encoding/json создавал GC pressure (15% CPU на GC)
  • Перешли на easyjson + sync.Pool для buffers
  • GC time упал до 3% CPU
  • (Финальное решение для них — Rust, но это другая история)

CockroachDB — distributed SQL DB на Go. SQL parser имеет zero-alloc:

  • Tokenizer работает на []byte без копии
  • AST nodes в arena (per-query)
  • Reset arena после query
  • Результат: parsing 100K+ queries/sec на одном CPU

  1. Что такое escape analysis в Go? Как проверить?
  2. Почему zero-alloc важен для high-throughput сервисов?
  3. Когда оптимизация на аллокации не оправдана?
  4. Как использовать go test -benchmem? Что означают B/op и allocs/op?
  5. Какие флаги компилятора покажут escape analysis?
  6. Как использовать sync.Pool правильно?
  7. Почему sync.Pool нельзя использовать как cache?
  8. В чём отличие strings.Builder от конкатенации через +?
  9. Когда использовать unsafe.String и какие риски?
  10. Что такое interface boxing и почему вызывает аллокации?
  11. Почему передача int в any аллоцирует?
  12. Что такое closure capture и как влияет на escape?
  13. Чем range over map хуже range over slice для аллокаций?
  14. Когда struct по value vs по pointer лучше?
  15. Что такое struct padding? Как минимизировать?
  16. Как использовать fieldalignment tool?
  17. Что такое arena allocator? Почему runtime.Arena abandoned?
  18. Какие альтернативы encoding/json для zero-alloc?
  19. В чём отличие easyjson от sonic?
  20. Что такое bounds check elimination и как проверить?
  21. Почему string([]byte) всегда копирует?
  22. Что такое sync.Pool 2-cycle eviction?
  23. Как минимизировать defer overhead?
  24. Какие гарантии даёт unsafe.Slice от Go 1.20+?
  25. Реальный кейс: вы видите в pprof что runtime.mallocgc 30% CPU. Как действовать?

Дан handler:

func handler(w http.ResponseWriter, r *http.Request) {
body, _ := io.ReadAll(r.Body)
var req Request
json.Unmarshal(body, &req)
resp := fmt.Sprintf(`{"id":%d,"status":"ok"}`, req.ID)
w.Write([]byte(resp))
}

Задача: переписать на zero-alloc (или максимально близко). Замерить через benchmark.

Написать middleware который logs request bodies. Использовать sync.Pool для bytes.Buffer, чтобы reuse’ить между запросами. Замерить улучшение через bench.

Взять реальный проект (свой или open source) и пройтись fieldalignment. Найти 5+ структур, которые можно уменьшить. Замерить размер до/после.

Реализовать slab allocator для Order структуры (трейдинг сценарий). Должен:

  • Pre-allocate 10000 объектов
  • Acquire/Release без аллокаций
  • Thread-safe

Сравнить с sync.Pool по latency.

Взять реальный JSON (10KB+), парсить через encoding/json, jsoniter, easyjson, sonic. Сравнить:

  • ns/op
  • B/op
  • allocs/op

Написать функцию BytesToString(b []byte) string через unsafe.String. Затем написать тест-кейс, который ломает immutability и приводит к UB (для понимания опасности).

Дан код с closures в hot loop:

for _, item := range items {
process(item, func() { logEvent(item.ID) })
}

Переписать без аллокаций closures (через interface или extracted function).

Реализовать HTTP handler который:

  • Парсит query params (без r.URL.Query() который аллоцирует map)
  • Формирует JSON response без encoding/json
  • 0 аллокаций на запрос

Замерить через wrk или vegeta.


  1. Go Performance Patternshttps://go.dev/doc/gc-guide — официальный GC guide.
  2. Dave Cheney, “High Performance Go Workshop”https://dave.cheney.net/high-performance-go-workshop
  3. Dmitry Vyukov, “Go scheduler”https://dvyukov.github.io/ — runtime internals.
  4. Brad Fitzpatrick, “Profiling Go” — talks on GopherCon.
  5. ByteDance sonichttps://github.com/bytedance/sonic
  6. easyjsonhttps://github.com/mailru/easyjson
  7. uber-go/zaphttps://github.com/uber-go/zap — zero-alloc logger.
  8. golang.org/x/tools/go/analysis/passes/fieldalignmenthttps://pkg.go.dev/golang.org/x/tools/go/analysis/passes/fieldalignment
  9. Russ Cox, “Escape Analysis”https://research.swtch.com/ — детальный разбор.
  10. Aerospike Go clienthttps://github.com/aerospike/aerospike-client-go — real-world example.
  11. CockroachDB performance bloghttps://www.cockroachlabs.com/blog/
  12. Cloudflare Go bloghttps://blog.cloudflare.com/tag/go/ — production case studies.