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

Go Memory Allocator: TCMalloc-style под капотом

Аллокатор Go — это адаптированная и упрощённая реализация TCMalloc (Thread-Caching Malloc) от Google. Три уровня иерархии: mcache (per-P) → mcentral (per size class) → mheap (global). Понимание этого нужно, чтобы объяснять heap profile, debug heap fragmentation, обосновать sync.Pool и оптимизировать аллокации в hot path. На Middle 2 вопросы про аллокатор — это второй после scheduler уровень фильтра. В Авито и Озон обязательно спросят про size classes, tiny allocator, разницу между inuse и alloc, как mcache синхронизируется с GC. В Яндексе любят углублять до radix tree heap arenas и Swiss Tables (Go 1.24+).

  1. Базовая концепция аллокатора (для разогрева)
  2. Глубокое погружение: mcache → mcentral → mheap, size classes, mspan
  3. Подводные камни аллокатора
  4. Производительность и реальные кейсы
  5. Вопросы на собесе Middle 2 (30+)
  6. Practice
  7. Источники

Go-аллокатор разделяет аллокации по размеру:

  • Tiny (< 16 байт, без указателей) — объединяются в один tiny-блок 16 байт.
  • Small (≤ 32 KB) — берутся из mcache по size class (67 размерных классов).
  • Large (> 32 KB) — напрямую из mheap, минуя кэши.

Иерархия:

G (горутина) → mcache (per-P) → mcentral (per size class) → mheap (global)
↑ ↑ ↑
lock-free лёгкий lock heavy lock
~5-10 ns ~50 ns при miss ~µs при miss

Главная идея: 99% аллокаций идут через mcache в lock-free режиме. Только когда mcache пуст, идём в mcentral (с lock на size class), и только когда mcentral пуст — в mheap.

Дополнительный слой: память берётся у ОС крупными arena (64 MB на amd64), а внутри управляется через mspan (span = непрерывный кусок страниц).


Go использует 67 (точно — 68, нулевой класс для tiny) size classes:

class bytes/obj bytes/span objects tail-waste
1 8 8192 1024 0
2 16 8192 512 0
3 24 8192 341 24
4 32 8192 256 0
5 48 8192 170 32
...
66 27264 65536 2 224
67 32768 65536 2 0

Каждый класс — это (размер объекта, размер span'а). Объект округляется до ближайшего класса. Например, аллокация 100 байт → класс с bytes/obj = 112, разница в 12 байт — это внутренняя фрагментация.

Почему именно эти числа? Подобраны эмпирически так, чтобы:

  • Внутренняя фрагментация ≤ 12.5% для большинства размеров.
  • Минимум tail-waste (хвост страницы, который не используется).
  • Размеры степени двойки в начале, дальше — арифметическая прогрессия с особым шагом.

Файл runtime/sizeclasses.go — автогенеренный, пересоздаётся скриптом runtime/mksizeclasses.go. Если меняют формулу — пересобирают.

⚠️ Округление вверх до size class может ощутимо удорожать память: аллокация 33 байт → класс 48 байт, +45% накладные.

type mcache struct {
// tiny allocator
tiny uintptr // указатель на текущий tiny-блок
tinyoffset uintptr // смещение внутри tiny-блока
tinyAllocs uintptr // счётчик tiny-аллокаций
// массив указателей на spans для каждого size class
// 68 размерных классов * 2 (с указателями и без) = 136
alloc [numSpanClasses]*mspan
stackcache [_NumStackOrders]stackfreelist // для stack growth
flushGen uint32 // версия GC, защита от использования старых spans
}

Ключевое:

  • alloc [136]*mspan — каждый элемент это указатель на mspan, из которого мы аллоцируем объекты этого size class. Аллокация — это просто “взять следующий свободный bit в bitmap span’а”.
  • tiny/tinyoffset — отдельный bump-allocator для tiny-объектов.
  • flushGen — отслеживает GC cycle. Если GC сделал sweep между прошлой аллокацией и нынешней — нужно “вернуть” текущий span (он мог быть просвипован) и взять новый.

⚠️ mcache per-P, не per-M. Когда M теряет P (syscall), мы не можем использовать его mcache.

Для объектов < 16 байт без указателей:

func mallocgc(size uintptr, typ *_type, needzero bool) unsafe.Pointer {
...
if size <= maxTinySize { // 16 байт
off := mcache.tinyoffset
// выравнивание
if size&7 == 0 {
off = alignUp(off, 8)
} else if size&3 == 0 {
off = alignUp(off, 4)
} else if size&1 == 0 {
off = alignUp(off, 2)
}
if off+size <= maxTinySize && mcache.tiny != 0 {
// fit в текущий tiny-блок
x := unsafe.Pointer(mcache.tiny + off)
mcache.tinyoffset = off + size
mcache.tinyAllocs++
return x
}
// не fit — аллоцируем новый tiny-блок 16 байт
span := mcache.alloc[tinySpanClass]
v := nextFreeFast(span)
if v == 0 {
v, _, _ = mcache.nextFree(tinySpanClass)
}
x := unsafe.Pointer(v)
...
mcache.tiny = uintptr(x)
mcache.tinyoffset = size
return x
}
...
}

Ключевые моменты:

  • Tiny-блок — это всегда 16 байт. Внутри него много мелких аллокаций “склеиваются” в один блок.
  • Tiny НЕ для объектов с указателями! GC должен точно знать, где находятся указатели в объекте. Tiny с указателями = GC будет искать указатель на середине блока, что криво.
  • При промахе по выравниванию (например, заняли 12 байт, нужно ещё 8 с alignment 8) — аллоцируется новый блок 16 байт.
  • Объекты в одном tiny-блоке имеют одинаковое время жизни? Нет — но если хоть один из них живой, весь tiny-блок не свипуется. Это потенциальная проблема для долгоживущих сервисов: один маленький залипший объект держит 16-байтовый блок.

Для объектов 16-32768 байт:

// быстрый путь
func (c *mcache) nextFreeFast(s *mspan) gclinkptr {
theBit := sys.Ctz64(s.allocCache) // count trailing zeros
if theBit < 64 {
result := s.freeindex + uint16(theBit)
if result < s.nelems {
freeidx := result + 1
if freeidx%64 == 0 && freeidx != s.nelems {
return 0 // нужно обновить cache
}
s.allocCache >>= uint(theBit + 1)
s.freeindex = freeidx
s.allocCount++
return gclinkptr(uintptr(result)*s.elemsize + s.base())
}
}
return 0
}

allocCache — это 64-битная маска “следующие 64 объекта свободны/заняты”. Аллокация = найти первый свободный bit (CTZ инструкция, один такт), сдвинуть кэш. Это очень быстро — ~5-10 ns.

Когда allocCache исчерпан → refillAllocCache (обновить из allocBits). Когда весь span исчерпан → refill() (взять новый span из mcentral).

type mcentral struct {
spanclass spanClass
// два списка spans: с свободными объектами, и полностью занятые.
// partial — это где есть, что аллоцировать.
// full — нечего, всё в работе.
partial [2]spanSet // двойной буфер: для текущего GC цикла + предыдущего
full [2]spanSet
}

Двойной буфер partial[0] и partial[1] — нужно для GC: на половине цикла мы должны различать “уже просвиплено” и “ещё нет”. При начале нового sweep’a буферы свапаются.

Когда mcache.refill():

func (c *mcache) refill(spc spanClass) {
s := c.alloc[spc]
if s != &emptymspan {
// вернуть текущий span в mcentral
mheap_.central[spc].mcentral.uncacheSpan(s)
}
s = mheap_.central[spc].mcentral.cacheSpan()
c.alloc[spc] = s
}

cacheSpan():

  1. Пробует partial списки (с unsweept → нужно sweep, со swept → готово).
  2. Если ничего нет — grow() → запрос новых страниц у mheap.
type mheap struct {
lock mutex
// 68 размерных классов, каждый имеет свой mcentral
central [numSpanClasses]struct {
mcentral mcentral
pad [...]byte // выравнивание под cache line
}
// page allocator (radix tree)
pages pageAlloc
// arenas — мап из адреса в heapArena
arenas [1 << arenaL1Bits]*[1 << arenaL2Bits]*heapArena
// span allocator
spanalloc fixalloc
cachealloc fixalloc
...
}

Здесь:

  • central — массив из 68×2 = 136 mcentral, по одному на каждый spanClass.
  • pages — page allocator (radix tree, см. ниже).
  • arenas — двухуровневый mapping адресов в heapArena (для GC scan).

На amd64 одна arena = 64 MB. Это блок страниц, выделенный у ОС через mmap (или VirtualAlloc на Windows).

0x000000c000000000 ────► arena 0 (64 MB)
├─ span 1 (24 страницы класса 5)
├─ span 2 (8 страниц класса 12)
├─ span 3 (24 страницы large 64 KB)
└─ ...
0x000000c004000000 ────► arena 1 (64 MB)
0x000000c008000000 ────► arena 2 (64 MB)
...

Каждая arena имеет связанные структуры:

  • bitmap (1 байт на 32 байта heap): какие байты heap-памяти содержат указатели (для GC scan).
  • spans (1 указатель на 8 KB страницу): мапит каждую страницу в её mspan.

ОС маппинг (mmap) — большой блок. Внутри arena Go сам управляет страницами.

type mspan struct {
next *mspan // intrusive linked list
prev *mspan
startAddr uintptr // адрес первого байта span
npages uintptr // количество страниц (8 KB каждая)
manualFreeList gclinkptr // для NoGC регионов
// size class info
spanclass spanClass
state mSpanStateBox // _MSpanDead / _MSpanInUse / _MSpanManual
needzero uint8
// аллокационные структуры
freeindex uint16 // следующий потенциально свободный слот
nelems uint16 // общее число объектов в span
allocCache uint64 // кэш битов "свободно/занято" (64 объекта вперёд)
allocBits *gcBits // битмап "аллоцировано"
gcmarkBits *gcBits // битмап "помечено GC"
allocCount uint16 // сколько аллоцировано
elemsize uintptr // размер одного объекта
}

allocBits и gcmarkBits — это битмапы по одному биту на объект:

  • allocBits[i] = 1 → объект i аллоцирован.
  • gcmarkBits[i] = 1 → GC mark phase нашёл его живым.

После sweep: allocBits := gcmarkBits. То есть “живые” объекты становятся “аллоцированными”, а “мёртвые” уходят в свободные.

┌──────────────┐
│ G │
│ (горутина) │
└──────┬───────┘
│ malloc(size)
┌────────────────────────┐
│ mcache (per-P) │
│ ┌──────────────────┐ │
│ │ tiny: 0xc00010 │ │ ← bump allocator <16 B
│ │ tinyoffset: 12 │ │
│ ├──────────────────┤ │
│ │ alloc[0..135] │ │ ← per size class
│ │ ┌──────┐ │ │
│ │ │class5│→ mspan │ │
│ │ │class6│→ mspan │ │
│ │ │ ... │ │ │
│ │ └──────┘ │ │
│ └──────────────────┘ │
└─────────┬──────────────┘
│ при miss (span исчерпан)
┌────────────────────────┐
│ mcentral[spanClass] │ ← global, lock per class
│ ┌──────────────────┐ │
│ │ partial[2] │ │ ← spans с свободными слотами
│ │ full[2] │ │ ← полностью занятые spans
│ └──────────────────┘ │
└─────────┬──────────────┘
│ при miss
┌────────────────────────┐
│ mheap (global) │
│ ┌──────────────────┐ │
│ │ pages (radix tree)│ │ ← page allocator
│ │ arenas[...] │ │ ← список heapArena
│ │ spanalloc │ │ ← fixalloc для span структур
│ └──────────────────┘ │
└─────────┬──────────────┘
│ при miss → mmap у ОС
[Linux kernel]
mmap(64 MB)

Для объектов больше 32 KB mcache не используется вообще:

func mallocgc(size uintptr, ...) unsafe.Pointer {
if size <= maxSmallSize {
... // small path
} else {
// large
span := largeAlloc(size, needzero, noscan)
x := unsafe.Pointer(span.base())
...
}
}
func largeAlloc(size uintptr, ...) *mspan {
npages := size >> _PageShift
if size&_PageMask != 0 {
npages++
}
return mheap_.allocSpan(npages, spanAllocHeap, spanClass)
}

То есть для большого объекта сразу выделяется отдельный mspan. Это позволяет:

  • Не фрагментировать size classes.
  • Сразу вернуть страницы в page-pool после освобождения (а не ждать “когда весь span освободится”).

⚠️ Многие аллокаций ~30-40 KB переходят границу 32 KB и попадают в large path → больше overhead. Тюнинг: уменьшить буферы до 32 KB.

До 1.14 страничный аллокатор был free-list. С 1.14 переписан на radix tree.

Идея: всё адресное пространство Go-heap (до 8 TB) разбито на иерархию. На каждом уровне summary-бит “есть ли свободные страницы под этим узлом”. Это даёт O(log N) поиск.

Конкретно: 5 уровней radix tree, каждый узел — pallocBits (структура с битмапом 512 страниц). Summary: для каждого узла храним max[start, max, end] — максимальная длина свободного хвоста, максимальный gap внутри, максимальный длинный gap. Это позволяет быстро находить “первый span из 24 свободных страниц подряд”.

Старый алгоритм был O(N) по списку → не масштабировался для крупных heap. Новый — O(log N) даже для heap в десятки GB.

GC mark bits хранятся НЕ в самом mspan, а в отдельной арене:

type heapArena struct {
bitmap [heapArenaBitmapBytes]byte // указатели info
spans [pagesPerArena]*mspan // page → span mapping
...
}

Mark bits (gcmarkBits) для span’а аллоцируются как отдельный gcBits объект, причём после каждого GC cycle старые allocBits становятся новыми gcmarkBits (swap), а старые освобождаются. Это zero-cost обновление статуса.

runtime.mallocgc — критический путь, его оптимизировали до предела:

// упрощённо
func mallocgc(size uintptr, typ *_type, needzero bool) unsafe.Pointer {
mp := acquirem() // disable preempt
mp.mallocing = 1
c := getMCache(mp) // mcache текущего P
var x unsafe.Pointer
if size <= maxSmallSize {
if noscan && size < maxTinySize {
// TINY path (см. выше)
...
} else {
// SMALL path
spc := makeSpanClass(...)
span := c.alloc[spc]
v := nextFreeFast(span)
if v == 0 {
v, span, _ = c.nextFree(spc)
}
x = unsafe.Pointer(v)
...
}
} else {
// LARGE path
...
}
// GC assist если allocated > debt
if shouldhelpgc { gcAssistAlloc(gp) }
mp.mallocing = 0
releasem(mp)
return x
}

В hot path (tiny/small):

  • ~5-10 ns на amd64.
  • Один CAS, нет, всё уже под mp.mallocing (atomically disabled preempt).
  • В кэше L1 — mcache.

mcache per-P даёт TLB-локальность: горутины на одном P используют одни и те же страницы, прогревают TLB. При перебросе горутины на другой P — TLB-промахи. На большой нагрузке это видно как ~3-5% разница в throughput.

Линейный (bump) allocator: просто инкрементить указатель, аллокация ~3 ns, но никогда не возвращает память. Используется в Go только для tiny.

Free-list: список свободных слотов одного размера. Аллокация — отвязать первый. ~5-10 ns. Используется для small.

Mixed: Go использует free-list для основной массы, но в tiny — bump.

go tool pprof http://localhost:6060/debug/pprof/heap

В heap profile есть две “точки зрения”:

  • inuse_space / inuse_objects — что прямо сейчас аллоцировано (live).
  • alloc_space / alloc_objects — что аллоцировалось за всё время (накопительно с начала).

⚠️ Если у тебя сервис, который аллоцирует много мелких объектов, но они быстро GC’едаются — inuse_space будет маленький, но alloc_space огромный. Высокий alloc → высокий GC pressure → CPU usage GC растёт.

Дефолт pprofinuse_space. Для оптимизации GC pressure меняй на -alloc_objects.

Внутренняя: округление до size class. 100 байт → 112 → 12 байт впустую. Минимизировано выбором size classes (≤ 12.5% обычно).

Внешняя: после череды alloc/free между spans остаются “дыры”. В Go это минимизировано тем, что:

  • В одном span — объекты одного size class. Свободные слоты автоматически переиспользуются.
  • Если span полностью пустой — он возвращается в mcentral, может быть переиспользован для другого size class (после очистки) или освобождён в mheap.

Реальная внешняя фрагментация в Go ≈ 5-15% от heap.

После того как mspan освобождён → его страницы возвращаются в pageAlloc. Но физические RAM-страницы остаются маппнутыми в процессе. Чтобы вернуть RAM в ОС, нужен madvise(MADV_DONTNEED) (или MADV_FREE).

Это делает scavenger (runtime/mgcscavenge.go). Он работает в фоне (отдельная горутина без P, как sysmon), и постепенно возвращает неиспользуемые страницы. С Go 1.16+ scavenger управляется на основе GOMEMLIMIT (см. файл про GC).

  • GODEBUG=gctrace=1 — печатает GC stats после каждого цикла: heap before/after, scan time, STW pauses.
  • GODEBUG=allocfreetrace=1 — печатает stacktrace каждой аллокации (DEPRECATED с 1.21, использовать tracing).
  • GODEBUG=madvdontneed=1 — заставить scavenger использовать MADV_DONTNEED вместо MADV_FREE. На Linux 5.x с MADV_FREE возврат памяти “ленивый”, RSS не падает сразу. С MADV_DONTNEED RSS сразу падает.
  • GODEBUG=memprofilerate=N — sampling rate для memory profile.

Go 1.24 заменил map implementation на Swiss Tables (изначально из abseil-cpp). Старая реализация использовала classic open-addressing с tombstones. Swiss Tables:

  • Группы по 16 слотов (SSE-friendly).
  • Метаданные (control byte): hash[H1] + слот status (empty/deleted/full).
  • SIMD-сравнение 16 слотов сразу.

Эффект: ~10-30% быстрее lookup, меньше memory overhead.

⚠️ Это не отдельная аллокаторная фича, но влияет на размер map. У некоторых пользователей heap уменьшался на 5-10% после обновления до 1.24.

sync.Pool — это userland-cache для переиспользования объектов:

var bufPool = sync.Pool{
New: func() any { return make([]byte, 4096) },
}
func handler(w http.ResponseWriter, r *http.Request) {
buf := bufPool.Get().([]byte)
defer bufPool.Put(buf[:0])
// use buf
}

Под капотом:

  • Per-P local pool (lock-free).
  • Per-P shared queue (доступен для steal другими P).
  • Global victim cache (для перенасыщения).

После каждого GC victim чистится. То есть sync.Pool НЕ гарантирует, что объект сохранится между GC. Но в условиях постоянной нагрузки — переиспользует эффективно.

⚠️ В sync.Pool кладите только объекты, которые не требуют определённого состояния (например, буферы — reset через buf[:0]). НЕ кладите объекты с открытыми ресурсами (file handle, conn).


3.1. ⚠️ Округление до size class — реальный оверхед

Заголовок раздела «3.1. ⚠️ Округление до size class — реальный оверхед»

Аллокация 33 байта → класс 48 → +45% памяти. На 100 млн объектов = 1.5 GB.

Решение: align структуры под size classes. Используй unsafe.Sizeof() для проверки.

3.2. ⚠️ Tiny block “залипает” из-за одного объекта

Заголовок раздела «3.2. ⚠️ Tiny block “залипает” из-за одного объекта»

Если в одном tiny-блоке (16 байт) 4 мелких объекта по 4 байта, и хоть один из них доступен от GC root — весь блок не свипуется. Один залипший 4-байтный объект держит 16 байт.

В долго-живущих сервисах на годы такая фрагментация может накопить десятки MB.

func foo() {
defer logger.Log(fmt.Sprintf("foo done in %v", time.Since(start)))
...
}

fmt.Sprintf всегда аллоцирует строку. Если foo в hot path — это ~100 ns + GC pressure. Лучше:

defer func() {
log.Printf("foo done in %v", time.Since(start))
}()

log.Printf использует sync.Pool для буфера.

  • make([]byte, n) — n байт инициализированы нулями, длина n. Аллокация = n + 24 (header слайса).
  • make([]byte, 0, n) — выделено n, длина 0. То же самое, без zero-fill.
  • make([]byte, n, m) — m выделено, n заполнено нулями.

needzero=false (no-scan для байт) даёт zero-fill через memclr. Это занимает время. Для больших слайсов это заметно.

for _, item := range items {
res := process(item) // process возвращает struct
cache = append(cache, res)
}

Если cache начинается с длины 0 — будут множественные re-аллокации (1, 2, 4, 8, …). Решение: cache := make([]Result, 0, len(items)).

m := make(map[string]int) // default initial size = 8
m := make(map[string]int, 1000) // 1000 hint, аллокация bucket array под ~1024

Без hint — будут множественные rehash’и при росте.

func foo(v any) {
fmt.Println(v) // v escape'ит, потому что fmt получает interface{}
}

Любая передача в any/interface{} обычно ведёт к escape — нужна heap-копия. В hot path это критично.

При высокой аллокации mallocgc вызывает gcAssistAlloc, чтобы текущая горутина помогла GC mark. Это видно в pprof как runtime.gcAssistAlloc. Если оно >5% CPU — у тебя GC pressure, нужно снижать аллокации.

Когда map уменьшается (удалили много элементов), её bucket array НЕ ужимается. Старая ёмкость остаётся. Если карта была 10M элементов и стала 100K — heap всё равно держит buckets под 10M.

Решение: пересоздать map через m2 := make(map[K]V, len(m1)); for k, v := range m1 { m2[k] = v }.

b := []byte("hello") // аллокация 5 байт
s := string(b) // аллокация 5 байт (копия)

В обе стороны — аллокация. С Go 1.20+ если “hello” — литерал, копии может не быть. Но []byte(myString) всегда копирует (compiler не знает, что string не уйдёт).

В hot path — unsafe.String / unsafe.Slice (Go 1.20+) — zero-copy, но твоя ответственность не модифицировать.

type Header struct {
Data uintptr
Len int
Cap int
}

Каждый слайс — это 24 байта (на amd64). Если в структуре много пустых слайсов — это overhead. Используй pointer-to-slice, если слайсы редко используются.

go func() { ... }() // если closure capture'ит локалы, они идут на heap

Лямбда без capture — может остаться на стеке. С capture — heap, особенно если capture’ит указатель на локальную переменную.

time.Now() сам по себе не аллоцирует (возвращает struct). Но time.Since(t) возвращает Duration (int64) — тоже без аллокации. Однако форматирование t.Format(...) — обычно аллоцирует.

Нельзя — []byte не comparable. Но string — комарpable. Преобразование string(b) копирует! Если ключ часто проверяется — это лишние аллокации.

Решение: использовать unsafe.String(unsafe.SliceData(b), len(b)) для zero-copy конверсии (Go 1.20+, очень осторожно).

runtime.GC() запускает GC, но память остаётся в Go heap. Чтобы вернуть в ОС: debug.FreeOSMemory() (но это блокирующий вызов). Или ждать scavenger.


Кейс (Озон 2024): гипер-нагруженный сервис делал log.Info("user " + userID + " action " + action) ~50K раз/сек. fmt.Sprintf на каждый log → 4 аллокации (4 строки). Итог: GC consumes 12% CPU.

Решение: structured logging (zap) с log.Info("action", zap.String("user", userID), zap.String("action", action)). zap использует pre-allocated buffer pool. GC упал до 3%.

Кейс (Tinkoff): сервис кэширования держал map[string]*Item с TTL. Каждые 10 минут — purge expired (95% записей). После purge runtime.NumGC() срабатывал, но heap не падал. RSS 8 GB вместо ожидаемых 800 MB.

Расследование показало: bucket arrays не ужимались. Решение: при purge — пересоздать map в новой переменной, swap.

Кейс (Авито, сервис с uptime 30+ дней): heap inuse 500 MB, alloc total 2 PB. RSS постепенно растёт.

Анализ через go tool pprof --diff_base показал: множество tiny-объектов (interface{} headers, time.Time с monotonic clock). Долгоживущие “залипшие” объекты держали tiny-блоки.

Решение: ребут раз в неделю. И профилировка взяла под контроль “fat” callsites.

Кейс (HFT-сервис): parsing FIX message делал 30+ аллокаций на сообщение. Throughput 100K msg/sec — это 3M alloc/sec → GC съедает 20% CPU.

Решение:

  • Pre-allocated arena: один большой []byte буфер на parsing session.
  • Slices ссылаются на части буфера (zero-copy).
  • После обработки сообщения — buf = buf[:0], переиспользуем.

Аллокаций упало до 2 на сообщение, GC до 2% CPU.

Кейс: сервис на Go 1.24 после обновления показал:

  • Lookup latency map[string]struct{} (~10M элементов): 38 ns → 28 ns.
  • Heap (включая overhead map): 1.8 GB → 1.6 GB.
  • GC scan time: -8%.

Кейс (двухсокетный сервер 96 ядер): при GOMAXPROCS=96 и активной аллокации size class 6 — mcentral[6].lock стал хотspot. Профиль показал runtime.(*mcentral).cacheSpan 15% CPU.

Решение: уменьшили GOMAXPROCS до 48 (одного сокета), запустили второй инстанс на другом сокете. Contention исчез.

Кейс: сервис в k8s pod-е с memory limit 4 GB. Heap inuse падает с 3 GB до 500 MB, но RSS остаётся 3 GB. Pod подвергается OOM на следующем спайке.

Причина: MADV_FREE на Linux — “lazy reclaim”, kernel заберёт страницы только при memory pressure. Решение: GODEBUG=madvdontneed=1 — использовать MADV_DONTNEED (RSS падает немедленно). Trade-off: при следующей аллокации страницы зануляются заново.

С Go 1.16+ это поведение управляется через GOMEMLIMIT (см. файл 3).


Q1. Опиши трёхуровневую иерархию аллокатора в Go.

mcache (per-P) → mcentral (per size class) → mheap (global). Аллокация сначала пытается из mcache (lock-free, ~5 ns), при miss — mcentral (lock на size class), при miss — mheap (heavy lock, может mmap у ОС).

Q2. Сколько size classes в Go и зачем они?

67 классов (плюс tiny). Каждый класс — это пара (размер объекта, размер span'а). Объект округляется до ближайшего класса. Цель — ограничить внутреннюю фрагментацию до ~12.5%.

Q3. Что такое tiny allocator?

Bump allocator для объектов < 16 байт без указателей. В один tiny-блок (16 байт) укладываются несколько мелких объектов. Не для объектов с указателями (GC не сможет точно scan’ить).

Q4. Что такое mspan?

Структура, описывающая span — непрерывный кусок страниц с объектами одного size class. Содержит startAddr, npages, freeindex, allocCache, allocBits, gcmarkBits.

Q5. Чем small allocations отличаются от large?

Small (≤ 32 KB) идут через mcache → mcentral. Large (> 32 KB) — напрямую через mheap (largeAlloc), для них выделяется отдельный mspan. Это экономит overhead для крупных объектов.

Q6. Что такое allocCache в mspan?

64-битная маска “следующие 64 объекта свободны/заняты”. Аллокация — find first set bit (CTZ инструкция), сдвинуть кэш. Очень быстрый путь (5 ns). При исчерпании — refill из allocBits.

Q7. Объясни разницу между inuse и alloc в heap profile.

inuse_space — что прямо сейчас в памяти. alloc_space — что аллоцировалось с момента старта (накопительно). Высокий alloc при низком inuse → высокий GC pressure.

Q8. Что такое heap arena?

Большой блок памяти (64 MB на amd64), выделенный у ОС через mmap. Внутри — pages (8 KB), которые группируются в spans. К каждой arena прицеплены bitmap (для GC scan) и spans mapping (page → span).

Q9. Как работает page allocator (Go 1.14+)?

Radix tree (5 уровней). Каждый узел хранит summary (max длина свободного хвоста/gap’а). Поиск span’а из N страниц — O(log N), масштабируется на десятки GB heap.

Q10. Что такое allocBits и gcmarkBits?

Битмапы 1 бит на объект в span’е. allocBits = “объект аллоцирован”. gcmarkBits = “GC mark phase нашёл объект живым”. После sweep allocBits := gcmarkBits.

Q11. Зачем mcache per-P, а не per-M?

Когда M уходит в syscall, P переотдаётся другому M. mcache должна сохраниться с P, чтобы новый M мог продолжить аллокацию на той же P без promotion в mheap.

Q12. Почему mcache использует двойной буфер partial[2] и full[2]?

Чтобы различать “sweept в текущем GC цикле” и “ещё не sweept”. При новом GC цикле буферы меняются местами.

Q13. Что такое spanClass и зачем spanClass = sizeClass*2 + noscan?

Span’ы делятся на “содержит указатели” и “только данные”. GC scan-ит только spans с указателями. spanClass кодирует и size, и noscan-флаг в одном значении.

Q14. Что произойдёт при make([]byte, 32 KB+1)?

Большая аллокация. Не идёт через mcache, сразу largeAlloc(mheap, 5 pages) → 5×8 KB = 40 KB. Чуть-чуть запас.

Q15. Как Go возвращает память в ОС?

scavenger — фоновая работа, которая делает madvise(MADV_DONTNEED) (или MADV_FREE) на освобождённые страницы. Управляется GOMEMLIMIT и runtime/debug.SetGCPercent.

Q16. Что такое GODEBUG=madvdontneed=1 и когда нужно?

Заставляет scavenger использовать MADV_DONTNEED (немедленное возвращение страниц kernel’у) вместо MADV_FREE (lazy reclaim). Нужно, если RSS должен падать сразу (например, в k8s с tight memory limit).

Q17. Объясни, как sync.Pool взаимодействует с аллокатором.

sync.Pool — это per-P userland cache. Get берёт из локального pool (lock-free), при miss — steal с других P, при miss — глобальный victim cache, при miss — вызов New(). Между GC циклами victim cache очищается.

Q18. Что такое Swiss Tables и когда они появились?

Go 1.24 — новая map implementation на основе abseil Swiss Tables. Группы по 16 слотов, SIMD-сравнение control byte. ~10-30% быстрее lookup, меньше memory overhead.

Q19. Почему map не shrink?

Когда удаляешь элементы, bucket array не уменьшается. Это compromise: rehashing при shrink — дорогой, и часто после уменьшения map снова растёт. Если нужен shrink — пересоздай map.

Q20. Как escape analysis влияет на аллокатор?

Если компилятор доказывает, что объект не выходит за пределы функции — он на стеке (free, не считается аллокацией). Если escape — heap. Это решается на этапе компиляции, аллокатор просто следует решению.

Q21. Объясни overhead interface{} (any).

Each any — это пара (type descriptor, value pointer) = 16 байт на amd64. Если value > указателя — обычно heap-allocated. Поэтому boxing в any почти всегда влечёт аллокацию.

Q22. Что такое heap fragmentation в Go?

Внутренняя (округление до size class, ~12.5%) — неизбежна. Внешняя (gaps между spans) — минимизирована тем, что spans переиспользуются между size classes. Реальная фрагментация ~5-15%.

Q23. Что покажет GODEBUG=gctrace=1?

После каждого GC:

gc 12 @1.234s 3%: 0.018+5.2+0.025 ms clock, 0.14+0.32/4.8/9.1+0.20 ms cpu, 8->10->5 MB, 9 MB goal, 8 P

Heap before → during mark → after sweep, goal heap, P count, CPU time на GC. Хороший показатель — 3% (доля CPU на GC).

Q24. Что такое stackcache в mcache?

Кэш свободных стеков различных размеров (для goroutine stack growth). Когда G аллоцирует новый стек, она берёт из этого кэша. При переполнении — переходит в mheap.

Q25. Может ли аллокация сама вызвать GC?

Да, через gcAssistAlloc. Если allocated >> GC scan progress, mallocgc заставляет горутину помочь GC mark — это “штраф” за быструю аллокацию.

Q26. Что произойдёт при make([]byte, 1024) без указателей?

Tiny path не подходит (>16 B). Small path → size class ~ 1024 (1152 байт после round-up). spanClass с noscan флагом (нет указателей). Объект пишется в span класса noscan-1024.

Q27. Что такое page (в смысле heap pages)?

8 KB блок памяти (на amd64). Span — это N pages. Все объекты в одном span — одного size class. Page — atom адресации в page allocator.

Q28. Почему GC scan не видит C-allocated memory?

GC scan-ит только heap arenas (наши mmap’нутые блоки). C-allocated через malloc()/mmap() напрямую — не tracked. Если C-память хранит указатель на Go-объект — GC может его собрать!

Решение: cgo.Handle (Go 1.17+) — runtime держит ссылку для тебя.

Q29. Что такое heap profile sampling?

memprofilerate=N — каждый N-й аллоцированный байт сэмплится. Дефолт N=512 KB. То есть профайл — статистический, не точный. Чтобы получить precise — runtime.MemProfileRate = 1.

Q30. Опиши, что произойдёт при b := make([]byte, 100); b = nil.

Аллокация small (size class 112) → +1 объект в span. После b = nil ссылок нет → следующий GC mark не пометит объект → sweep вернёт его в free слоты span’а → новый объект (того же класса) переиспользует слот.


Напиши программу, которая аллоцирует 100K объектов размеров 1, 10, 17, 33, 80, 200, 600, 4000, 30000, 40000 байт. Замерь heap (через runtime.ReadMemStats). Сравни с теоретическим (sum sizes). Объясни overhead каждого класса.

Напиши код, который аллоцирует 8 × *byte (пустой struct без указателей < 16 байт) подряд. Через unsafe.Pointer(&p[0]) посмотри адреса — соседи должны быть в одном tiny-блоке (адреса близкие).

Запусти бенчмарк: G аллоцирует 1000 × 64-byte объектов. Сравни с GOMAXPROCS=1 и GOMAXPROCS=8 с pinning к одному CPU vs к разным. Объясни, как mcache per-P даёт locality benefit.

Сравни:

  • make([]byte, 4096) в цикле.
  • sync.Pool Get/Put + buf = buf[:0].

Замерь allocations через b.ReportAllocs(), GC pressure через pprof. Объясни выгоду sync.Pool.

Запусти сервис, сделай baseline profile (go tool pprof -base). Аллоцируй много объектов. Сделай second profile, посмотри diff. Найди топ-3 аллокатора.

Создай map[int]int с 1M элементов. Удали 99%. Замерь heap. Пересоздай map. Замерь снова. Объясни разницу.

Запусти программу с GODEBUG=madvdontneed=1 и без. Аллоцируй 1 GB, освободи. Замерь RSS через /proc/self/status. Объясни разницу.

Сравни производительность make([]byte, 8) и make([]byte, 24). Первое идёт через tiny path, второе — small. Объясни разницу в naseconds/op.


  1. src/runtime/malloc.gomallocgc, tiny path, small path, large path.
  2. src/runtime/mcache.go — структура mcache, refill.
  3. src/runtime/mcentral.go — структура mcentral, cacheSpan/uncacheSpan.
  4. src/runtime/mheap.go — структура mheap, allocSpan, arenas.
  5. src/runtime/sizeclasses.go — таблица 67 классов (автогенеренная).
  6. src/runtime/mksizeclasses.go — генератор size classes.
  7. src/runtime/mgcscavenge.go — scavenger.
  8. Sanjay Ghemawat, Paul Menage, “TCMalloc: Thread-Caching Malloc” — оригинальная статья Google (2007).
  9. Austin Clements, “Go Memory Allocator Design Doc” — doc/articles/.
  10. “A visual guide to Go Memory Allocator from scratch” — Ankur Anand, blog (2018, переведено на Habr).
  11. “The Swiss Army Knife: Go’s new map” — Go team blog, Go 1.24 release notes.
  12. “Memory and the Go runtime” — William Kennedy, Ardan Labs blog.
  13. “Garbage Collection in Go: A Hands-On Approach” — Habr 2024, Avito Engineering.