22. Tiny Allocator и маленькие объекты
В статье 21. Аллокатор памяти Go. mcache, mcentral, mheap мы подробно исследовали трехуровневую архитектуру аллокатора TCMalloc. Мы увидели, что рантайм Go квантует память кучи на 67 фиксированных классов размеров (Size Classes).
Однако при внимательном анализе таблицы классов легко обнаружить серьезную архитектурную проблему. Минимальный стандартный класс размера выделяет непрерывный слот объемом 8 байт.
Что произойдет, если высоконагруженный бэкенд-сервис обрабатывает миллионы событий и аллоцирует множество микроскопических переменных типа bool (размер 1 байт), которые из-за передачи в замыкание или интерфейс неизбежно «убегают» в кучу (Escape Analysis)?
Если бы рантайм послушно выделял под каждый такой bool полноценный слот на 8 байт, 7 байт неизбежно превращались бы в бесполезный балласт. Это 87% внутренней фрагментации! В масштабах промышленных микросервисов с гигабайтами оперативной памяти такое расточительство приводило бы к катастрофической потере полезного пространства и забиванию кэш-линий процессора нулями.
Чтобы устранить эту проблему, создатели Go встроили прямо в локальный кэш mcache специализированный микромеханизм — Tiny Allocator (Крошечный аллокатор).
Что такое Tiny Allocator?
Tiny Allocator — это компактный механизм сверхплотной упаковки микроскопических объектов, работающий непосредственно внутри локального контекста процессора P.
Он активируется компилятором и рантаймом исключительно при одновременном выполнении двух жестких условий:
- Размер запрашиваемого объекта строго меньше 16 байт (например, базовые скалярные типы
bool,int8,int16,int32,float32илиfloat64). - Объект гарантированно не содержит указателей (noscan).
Механика упаковки (Tiny Block)
Внутри структуры локального кэша mcache (намертво закрепленного за логическим процессором P) зарезервированы три специальных системных поля:
tiny: прямой адрес начала текущего 16-байтного блока.tinyoffset: текущее смещение в байтах (указывает, сколько байт блока уже занято ранее размещенными объектами).local_tinyallocs: локальный счетчик крошечных аллокаций для снятия профилей и статистики рантайма.
Когда горутина аллоцирует независимый bool (1 байт), рантайм запрашивает у mcache свободный слот размером 16 байт (Tiny Block). Он размещает наш bool по смещению 0, устанавливает значение tinyoffset = 1 и возвращает указатель вызывающему коду.
Когда та же горутина следом запрашивает еще один bool, рантайм не обращается за новым слотом! Он просто размещает второй bool в тот же самый блок по смещению 1 и инкрементирует tinyoffset = 2. До тех пор пока 16-байтный блок не заполнится до конца, аллокации выполняются буквально за 2–3 машинных такта.
flowchart LR
classDef process fill:#1e293b,stroke:#38bdf8,stroke-width:1px,color:#f8fafc
classDef success fill:#064e3b,stroke:#34d399,stroke-width:1px,color:#f8fafc
classDef error fill:#7f1d1d,stroke:#f87171,stroke-width:1px,color:#f8fafc
classDef warning fill:#78350f,stroke:#fbbf24,stroke-width:1px,color:#f8fafc
classDef entry fill:#1e3a5f,stroke:#38bdf8,stroke-width:2px,color:#f8fafc
classDef aux fill:#334155,stroke:#64748b,stroke-width:1px,color:#f8fafc
classDef system fill:#312e81,stroke:#a78bfa,stroke-width:1px,color:#f8fafc
classDef data fill:#132d29,stroke:#2dd4bf,stroke-width:1px,color:#f8fafc
classDef network fill:#881337,stroke:#fb7185,stroke-width:1px,color:#f8fafc
classDef accent fill:#1e1b4b,stroke:#818cf8,stroke-width:1px,color:#f8fafc
subgraph MCACHE ["mcache процессора P"]
direction TB
TINY["tiny pointer"] --> BLOCK
OFFSET["tinyoffset: 5"] -.-> SLOT3
end
subgraph BLOCK ["Tiny Block - 16 байт"]
direction LR
SLOT1["bool<br>1 байт (смещение 0)"]
SLOT2["int32<br>4 байта (смещение 1..4)"]
SLOT3["Свободно<br>11 байт (смещение 5..15)"]
SLOT1 ~~~ SLOT2 ~~~ SLOT3
end
Выравнивание памяти (Alignment)
Физический процессор требует строгого соблюдения выравнивания адресов. На современных архитектурах чтение 64-битного числа из адреса, не кратного 8, либо влечет штраф в десятки тактов из-за расщепления доступа между двумя кэш-линиями (Split Lock), либо вовсе завершается аппаратным сбоем (Alignment Trap, см. 16. sync/atomic и атомарные операции в рантайме).
Tiny Allocator неукоснительно соблюдает эти аппаратные законы. Это приводит к важному следствию: порядок поступления мелких аллокаций определяет объем полезно упакованной памяти.
Представьте сценарий, в котором код последовательно аллоцирует сначала bool (1 байт), а затем int64 (8 байт):
- Байт
boolукладывается по смещению0. Значениеtinyoffsetстановится равным1. - Рантайм пытается разместить
int64. Но для типаint64адрес в памяти обязан быть строго кратен 8! - Аллокатор вынужден выполнить выравнивание: он сдвигает
tinyoffsetс1сразу на8. - Байт 0 занят, байты с 1 по 7 остаются неиспользованной пустотой (пустой padding). Число
int64занимает слоты с 8 по 15 байт. В результате весь 16-байтный Tiny Block оказывается полностью исчерпан.
Мы запросили всего $1 + 8 = 9$ байт полезных данных, но из-за правил выравнивания израсходовали все 16 байт блока.
Mechanical Sympathy. Оптимизация структур
Рассмотрим хрестоматийный пример с технического собеседования. Сколько байт занимает в памяти следующая структура на 64-битной платформе?
type BadStruct struct {
A bool // 1 байт
B int64 // 8 байт
C bool // 1 байт
}
Наивный подсчет дает: $1 + 8 + 1 = 10$ байт.
Однако реальность кремния иная:
- Поле
Aзанимает байт со смещением 0. - Полю
B(int64) требуется адрес, кратный 8. Компилятор вставляет 7 байт пустоты (padding) со смещений 1 по 7.Bзанимает байты 8–15. - Поле
Cзанимает байт 16. - Но размер всей структуры обязан быть кратен максимальному выравниванию ее полей (8 байт), чтобы при размещении в массиве
[]BadStructкаждый элемент начинался с адреса, кратного 8. Компилятор добавляет еще 7 байт добивки в конец структуры (байты 17–23).
Итоговый размер BadStruct: 24 байта!
При аллокации в куче этот объект потребует слот класса 24 байта (а с учетом округления аллокатора — возможно, и 32 байта).
А теперь применим Mechanical Sympathy и упорядочим поля структуры от наибольших к наименьшим:
type GoodStruct struct {
B int64 // 8 байт
A bool // 1 байт
C bool // 1 байт
}
Поле B занимает байты 0–7. Поле A ложится со смещением 8. Поле C размещается следом со смещением 9. Добивка структуры до кратности 8 требует всего 6 байт в конце (байты 10–15).
Итоговый размер GoodStruct: ровно 16 байт!
Мы мгновенно сэкономили 33% оперативной памяти, не изменив ни единой строчки бизнес-логики программы.
flowchart TB
classDef process fill:#1e293b,stroke:#38bdf8,stroke-width:1px,color:#f8fafc
classDef success fill:#064e3b,stroke:#34d399,stroke-width:1px,color:#f8fafc
classDef error fill:#7f1d1d,stroke:#f87171,stroke-width:1px,color:#f8fafc
classDef warning fill:#78350f,stroke:#fbbf24,stroke-width:1px,color:#f8fafc
classDef entry fill:#1e3a5f,stroke:#38bdf8,stroke-width:2px,color:#f8fafc
classDef aux fill:#334155,stroke:#64748b,stroke-width:1px,color:#f8fafc
classDef system fill:#312e81,stroke:#a78bfa,stroke-width:1px,color:#f8fafc
classDef data fill:#132d29,stroke:#2dd4bf,stroke-width:1px,color:#f8fafc
classDef network fill:#881337,stroke:#fb7185,stroke-width:1px,color:#f8fafc
classDef accent fill:#1e1b4b,stroke:#818cf8,stroke-width:1px,color:#f8fafc
subgraph BAD ["BadStruct - 24 байта (14 байт мусора)"]
direction LR
B_A["A (bool)<br>1B"]:::process --> B_PAD1["Padding<br>7B"]
B_PAD1 --> B_B["B (int64)<br>8B"]
B_B --> B_C["C (bool)<br>1B"]
B_C --> B_PAD2["Padding<br>7B"]
end
subgraph GOOD ["GoodStruct - 16 байт (6 байт мусора)"]
direction LR
G_B["B (int64)<br>8B"]:::process --> G_A["A (bool)<br>1B"]
G_A --> G_C["C (bool)<br>1B"]
G_C --> G_PAD["Padding<br>6B"]
end
Итог
- Tiny Allocator — специализированный компонент внутри локального кэша
mcache, предотвращающий внутреннюю фрагментацию памяти при массовом создании мелких объектов. - Он группирует объекты размером менее 16 байт в единые 16-байтные кванты памяти.
- Механизм принимает исключительно объекты без указателей (noscan), чтобы не усложнять и не замедлять работу Сборщика мусора.
- Понимание аппаратных требований к выравниванию памяти (Memory Alignment) позволяет сокращать объем структур данных на 20–40%, экономя гигабайты RAM в высоконагруженных бэкендах.
Мы досконально изучили всю цепочку выделения динамической памяти: от стека горутины до иерархии mheap и микро-аллокатора Tiny Allocator. Рантайм Go делает всё возможное, чтобы процедура аллокации работала молниеносно.
Но в компьютерных науках остается непреложная истина: самая быстрая аллокация — это та, которую удалось полностью предотвратить.
Если ваш сервис вынужден создавать тяжелые буферы на каждый входящий сетевой запрос, даже самый быстрый аллокатор рано или поздно перегрузит Сборщик мусора. Выход один — повторное использование уже выделенной памяти.
В следующей главе мы разберем механизм, позволяющий эффективно переиспользовать память в обход аллокатора кучи:
23. sync.Pool под капотом