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+).
Содержание
Заголовок раздела «Содержание»- Базовая концепция аллокатора (для разогрева)
- Глубокое погружение: mcache → mcentral → mheap, size classes, mspan
- Подводные камни аллокатора
- Производительность и реальные кейсы
- Вопросы на собесе Middle 2 (30+)
- Practice
- Источники
1. Базовая концепция (разогрев)
Заголовок раздела «1. Базовая концепция (разогрев)»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 = непрерывный кусок страниц).
2. Глубокое погружение
Заголовок раздела «2. Глубокое погружение»2.1. Size classes (runtime/sizeclasses.go)
Заголовок раздела «2.1. Size classes (runtime/sizeclasses.go)»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% накладные.
2.2. Структура mcache (runtime/mcache.go)
Заголовок раздела «2.2. Структура mcache (runtime/mcache.go)»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.
2.3. Tiny allocator
Заголовок раздела «2.3. Tiny allocator»Для объектов < 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-байтовый блок.
2.4. Small allocator: aged hot path
Заголовок раздела «2.4. Small allocator: aged hot path»Для объектов 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).
2.5. Структура mcentral (runtime/mcentral.go)
Заголовок раздела «2.5. Структура mcentral (runtime/mcentral.go)»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():
- Пробует partial списки (с unsweept → нужно sweep, со swept → готово).
- Если ничего нет —
grow()→ запрос новых страниц у mheap.
2.6. Структура mheap (runtime/mheap.go)
Заголовок раздела «2.6. Структура mheap (runtime/mheap.go)»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).
2.7. Heap arenas
Заголовок раздела «2.7. Heap arenas»На 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 сам управляет страницами.
2.8. Структура mspan
Заголовок раздела «2.8. Структура mspan»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. То есть “живые” объекты становятся “аллоцированными”, а “мёртвые” уходят в свободные.
2.9. ASCII-схема mcache → mcentral → mheap
Заголовок раздела «2.9. ASCII-схема mcache → mcentral → mheap» ┌──────────────┐ │ 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)2.10. Large allocations (> 32 KB)
Заголовок раздела «2.10. Large allocations (> 32 KB)»Для объектов больше 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.
2.11. Page allocator: radix tree (Go 1.14+)
Заголовок раздела «2.11. Page allocator: radix tree (Go 1.14+)»До 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.
2.12. Mark bits — отдельная структура
Заголовок раздела «2.12. Mark bits — отдельная структура»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 обновление статуса.
2.13. Allocation hot path: ASM-уровень
Заголовок раздела «2.13. Allocation hot path: ASM-уровень»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.
2.14. TLB locality
Заголовок раздела «2.14. TLB locality»mcache per-P даёт TLB-локальность: горутины на одном P используют одни и те же страницы, прогревают TLB. При перебросе горутины на другой P — TLB-промахи. На большой нагрузке это видно как ~3-5% разница в throughput.
2.15. Linear vs free-list
Заголовок раздела «2.15. Linear vs free-list»Линейный (bump) allocator: просто инкрементить указатель, аллокация ~3 ns, но никогда не возвращает память. Используется в Go только для tiny.
Free-list: список свободных слотов одного размера. Аллокация — отвязать первый. ~5-10 ns. Используется для small.
Mixed: Go использует free-list для основной массы, но в tiny — bump.
2.16. Allocation profile: inuse vs alloc
Заголовок раздела «2.16. Allocation profile: inuse vs alloc»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 растёт.
Дефолт pprof — inuse_space. Для оптимизации GC pressure меняй на -alloc_objects.
2.17. Fragmentation
Заголовок раздела «2.17. Fragmentation»Внутренняя: округление до 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.
2.18. Возврат памяти в ОС: scavenger
Заголовок раздела «2.18. Возврат памяти в ОС: scavenger»После того как mspan освобождён → его страницы возвращаются в pageAlloc. Но физические RAM-страницы остаются маппнутыми в процессе. Чтобы вернуть RAM в ОС, нужен madvise(MADV_DONTNEED) (или MADV_FREE).
Это делает scavenger (runtime/mgcscavenge.go). Он работает в фоне (отдельная горутина без P, как sysmon), и постепенно возвращает неиспользуемые страницы. С Go 1.16+ scavenger управляется на основе GOMEMLIMIT (см. файл про GC).
2.19. GODEBUG для аллокатора
Заголовок раздела «2.19. GODEBUG для аллокатора»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.
2.20. Swiss Tables (Go 1.24+)
Заголовок раздела «2.20. Swiss Tables (Go 1.24+)»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.
2.21. sync.Pool и аллокатор
Заголовок раздела «2.21. sync.Pool и аллокатор»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. Подводные камни аллокатора
Заголовок раздела «3. Подводные камни аллокатора»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.
3.3. ⚠️ Аллокация в defer
Заголовок раздела «3.3. ⚠️ Аллокация в defer»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 для буфера.
3.4. ⚠️ make([]byte, n) vs make([]byte, 0, n) vs make([]byte, n, m)
Заголовок раздела «3.4. ⚠️ make([]byte, n) vs make([]byte, 0, n) vs make([]byte, n, m)»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. Это занимает время. Для больших слайсов это заметно.
3.5. ⚠️ Аллокация перед for range
Заголовок раздела «3.5. ⚠️ Аллокация перед for range»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)).
3.6. ⚠️ Map с pre-allocation
Заголовок раздела «3.6. ⚠️ Map с pre-allocation»m := make(map[string]int) // default initial size = 8m := make(map[string]int, 1000) // 1000 hint, аллокация bucket array под ~1024Без hint — будут множественные rehash’и при росте.
3.7. ⚠️ Escape to heap из-за reflection
Заголовок раздела «3.7. ⚠️ Escape to heap из-за reflection»func foo(v any) { fmt.Println(v) // v escape'ит, потому что fmt получает interface{}}Любая передача в any/interface{} обычно ведёт к escape — нужна heap-копия. В hot path это критично.
3.8. ⚠️ mallocgc тратит время на GC assist
Заголовок раздела «3.8. ⚠️ mallocgc тратит время на GC assist»При высокой аллокации mallocgc вызывает gcAssistAlloc, чтобы текущая горутина помогла GC mark. Это видно в pprof как runtime.gcAssistAlloc. Если оно >5% CPU — у тебя GC pressure, нужно снижать аллокации.
3.9. ⚠️ Memory amplification из-за map shrink
Заголовок раздела «3.9. ⚠️ Memory amplification из-за map shrink»Когда map уменьшается (удалили много элементов), её bucket array НЕ ужимается. Старая ёмкость остаётся. Если карта была 10M элементов и стала 100K — heap всё равно держит buckets под 10M.
Решение: пересоздать map через m2 := make(map[K]V, len(m1)); for k, v := range m1 { m2[k] = v }.
3.10. ⚠️ string и []byte конверсии
Заголовок раздела «3.10. ⚠️ string и []byte конверсии»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, но твоя ответственность не модифицировать.
3.11. ⚠️ Слайс заголовок и аллокация
Заголовок раздела «3.11. ⚠️ Слайс заголовок и аллокация»type Header struct { Data uintptr Len int Cap int}Каждый слайс — это 24 байта (на amd64). Если в структуре много пустых слайсов — это overhead. Используй pointer-to-slice, если слайсы редко используются.
3.12. ⚠️ Closure-аллокация
Заголовок раздела «3.12. ⚠️ Closure-аллокация»go func() { ... }() // если closure capture'ит локалы, они идут на heapЛямбда без capture — может остаться на стеке. С capture — heap, особенно если capture’ит указатель на локальную переменную.
3.13. ⚠️ time.Now() и аллокация
Заголовок раздела «3.13. ⚠️ time.Now() и аллокация»time.Now() сам по себе не аллоцирует (возвращает struct). Но time.Since(t) возвращает Duration (int64) — тоже без аллокации. Однако форматирование t.Format(...) — обычно аллоцирует.
3.14. ⚠️ []byte в map ключ?
Заголовок раздела «3.14. ⚠️ []byte в map ключ?»Нельзя — []byte не comparable. Но string — комарpable. Преобразование string(b) копирует! Если ключ часто проверяется — это лишние аллокации.
Решение: использовать unsafe.String(unsafe.SliceData(b), len(b)) для zero-copy конверсии (Go 1.20+, очень осторожно).
3.15. ⚠️ runtime.GC() ≠ освобождение в ОС
Заголовок раздела «3.15. ⚠️ runtime.GC() ≠ освобождение в ОС»runtime.GC() запускает GC, но память остаётся в Go heap. Чтобы вернуть в ОС: debug.FreeOSMemory() (но это блокирующий вызов). Или ждать scavenger.
4. Производительность и реальные кейсы
Заголовок раздела «4. Производительность и реальные кейсы»4.1. GC pressure из-за fmt.Sprintf в логировании
Заголовок раздела «4.1. GC pressure из-за fmt.Sprintf в логировании»Кейс (Озон 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%.
4.2. Heap amplification из-за map
Заголовок раздела «4.2. Heap amplification из-за map»Кейс (Tinkoff): сервис кэширования держал map[string]*Item с TTL. Каждые 10 минут — purge expired (95% записей). После purge runtime.NumGC() срабатывал, но heap не падал. RSS 8 GB вместо ожидаемых 800 MB.
Расследование показало: bucket arrays не ужимались. Решение: при purge — пересоздать map в новой переменной, swap.
4.3. Tiny-fragmentation в long-running
Заголовок раздела «4.3. Tiny-fragmentation в long-running»Кейс (Авито, сервис с 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.
4.4. Allocation hot path optimization
Заголовок раздела «4.4. Allocation hot path optimization»Кейс (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.
4.5. Swiss Tables (Go 1.24+) — реальный эффект
Заголовок раздела «4.5. Swiss Tables (Go 1.24+) — реальный эффект»Кейс: сервис на 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%.
4.6. mcentral contention на NUMA
Заголовок раздела «4.6. mcentral contention на NUMA»Кейс (двухсокетный сервер 96 ядер): при GOMAXPROCS=96 и активной аллокации size class 6 — mcentral[6].lock стал хотspot. Профиль показал runtime.(*mcentral).cacheSpan 15% CPU.
Решение: уменьшили GOMAXPROCS до 48 (одного сокета), запустили второй инстанс на другом сокете. Contention исчез.
4.7. RSS не падает после освобождения
Заголовок раздела «4.7. RSS не падает после освобождения»Кейс: сервис в 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).
5. Вопросы на собесе Middle 2
Заголовок раздела «5. Вопросы на собесе Middle 2»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 PHeap 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’а → новый объект (того же класса) переиспользует слот.
6. Practice
Заголовок раздела «6. Practice»Задача 1. Анализ size class fragmentation
Заголовок раздела «Задача 1. Анализ size class fragmentation»Напиши программу, которая аллоцирует 100K объектов размеров 1, 10, 17, 33, 80, 200, 600, 4000, 30000, 40000 байт. Замерь heap (через runtime.ReadMemStats). Сравни с теоретическим (sum sizes). Объясни overhead каждого класса.
Задача 2. Tiny-allocator демонстрация
Заголовок раздела «Задача 2. Tiny-allocator демонстрация»Напиши код, который аллоцирует 8 × *byte (пустой struct без указателей < 16 байт) подряд. Через unsafe.Pointer(&p[0]) посмотри адреса — соседи должны быть в одном tiny-блоке (адреса близкие).
Задача 3. mcache locality
Заголовок раздела «Задача 3. mcache locality»Запусти бенчмарк: G аллоцирует 1000 × 64-byte объектов. Сравни с GOMAXPROCS=1 и GOMAXPROCS=8 с pinning к одному CPU vs к разным. Объясни, как mcache per-P даёт locality benefit.
Задача 4. sync.Pool benchmark
Заголовок раздела «Задача 4. sync.Pool benchmark»Сравни:
make([]byte, 4096)в цикле.sync.PoolGet/Put +buf = buf[:0].
Замерь allocations через b.ReportAllocs(), GC pressure через pprof. Объясни выгоду sync.Pool.
Задача 5. Heap profile diff
Заголовок раздела «Задача 5. Heap profile diff»Запусти сервис, сделай baseline profile (go tool pprof -base). Аллоцируй много объектов. Сделай second profile, посмотри diff. Найди топ-3 аллокатора.
Задача 6. Map shrink workaround
Заголовок раздела «Задача 6. Map shrink workaround»Создай map[int]int с 1M элементов. Удали 99%. Замерь heap. Пересоздай map. Замерь снова. Объясни разницу.
Задача 7. MADV_DONTNEED vs MADV_FREE
Заголовок раздела «Задача 7. MADV_DONTNEED vs MADV_FREE»Запусти программу с GODEBUG=madvdontneed=1 и без. Аллоцируй 1 GB, освободи. Замерь RSS через /proc/self/status. Объясни разницу.
Задача 8. tiny vs small benchmark
Заголовок раздела «Задача 8. tiny vs small benchmark»Сравни производительность make([]byte, 8) и make([]byte, 24). Первое идёт через tiny path, второе — small. Объясни разницу в naseconds/op.
7. Источники
Заголовок раздела «7. Источники»src/runtime/malloc.go—mallocgc, tiny path, small path, large path.src/runtime/mcache.go— структура mcache, refill.src/runtime/mcentral.go— структура mcentral, cacheSpan/uncacheSpan.src/runtime/mheap.go— структура mheap, allocSpan, arenas.src/runtime/sizeclasses.go— таблица 67 классов (автогенеренная).src/runtime/mksizeclasses.go— генератор size classes.src/runtime/mgcscavenge.go— scavenger.- Sanjay Ghemawat, Paul Menage, “TCMalloc: Thread-Caching Malloc” — оригинальная статья Google (2007).
- Austin Clements, “Go Memory Allocator Design Doc” —
doc/articles/. - “A visual guide to Go Memory Allocator from scratch” — Ankur Anand, blog (2018, переведено на Habr).
- “The Swiss Army Knife: Go’s new map” — Go team blog, Go 1.24 release notes.
- “Memory and the Go runtime” — William Kennedy, Ardan Labs blog.
- “Garbage Collection in Go: A Hands-On Approach” — Habr 2024, Avito Engineering.