21. Аллокатор памяти Go. mcache, mcentral, mheap
В статье 20. Stack vs Heap в Go мы установили, что Куча (Heap) — это принципиально более сложная и медленная область памяти по сравнению со стеком. Однако главная причина замедления кучи в современной многопоточной системе — это вовсе не только аппаратные промахи кэша (Cache Misses), а разрушительная конкуренция за блокировки (Lock Contention).
Представьте классическую реализацию функции malloc в языке C эпохи 1990-х годов. Это глобальная процедура, управляющая единым системным пулом оперативной памяти через один глобальный мьютекс. Если 16 или 64 физических ядра современного сервера одновременно захотят выделить память для своих объектов, 15 или 63 из них мгновенно выстроятся в очередь и уснут на примитиве синхронизации, пока одно-единственное ядро перебирает связный список свободных блоков. В высоконагруженном бэкенде такая архитектура приводит к падению пропускной способности в десятки раз.
Чтобы Go мог эффективно масштабироваться на сотни тысяч и миллионы конкурентных горутин, языку требовался аллокатор принципиально иного класса: способный выделять память параллельно, в подавляющем большинстве случаев без единой блокировки. Для решения этой грандиозной задачи инженеры Go взяли за основу архитектуру TCMalloc (Thread-Caching Malloc), изначально спроектированную Санджеем Гемаватом (Sanjay Ghemawat) в Google для высоконагруженных C++ систем.
Фундаментальная идея TCMalloc — многоуровневая иерархия кэшей. Рантайм Go делит управление динамической памятью на три четко разграниченных уровня: mcache, mcentral и mheap.
Анатомия памяти: Page и mspan
Прежде чем исследовать уровни кэширования, необходимо понять физические единицы, которыми оперирует аллокатор. Рантайм Go принципиально не работает с произвольными байтами. Он мыслит крупными унифицированными квантами:
- Page (Страница): Базовая аппаратная единица памяти, запрашиваемая у операционной системы. В рантайме Go размер виртуальной страницы фиксирован и равен 8 КБ (в Linux базовая страница архитектуры x86-64 обычно равна 4 КБ; рантайм Go объединяет их попарно, абстрагируя системные детали).
- mspan (Span): Фундаментальный строительный блок аллокатора. Структура
mspanпредставляет собой заголовок, управляющий непрерывным блоком из одной или нескольких страниц (8 КБ, 16 КБ, 24 КБ и т.д.).
Сам по себе mspan — это просто сырой непрерывный диапазон адресов. Чтобы эффективно размещать в нем структуры пользовательского кода, рантайм предварительно нарезает mspan на одинаковые ячейки строго фиксированного размера. Эта концепция называется Size Classes (Классы размеров).
В рантайме Go определено ровно 67 классов размеров для малых объектов (от 8 байт до 32 КБ):
- Класс 1: Ячейки по 8 байт.
- Класс 2: Ячейки по 16 байт.
- Класс 3: Ячейки по 24 байта.
- ...
- Класс 67: Ячейки по 32 768 байт (32 КБ).
Если mspan размером в одну страницу 8 КБ (8192 байта) назначен для Класса 2 (16 байт), он математически распиливается на 512 готовых слотов по 16 байт ($8192 / 16 = 512$). Каждый такой слот отслеживается битовой маской аллокации.
Иерархия аллокатора: Три кита
Посмотрим, как предварительно нарезанные блоки mspan перемещаются между ядрами процессора и горутинами.
flowchart TD
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
P["Логический процессор P"]:::data --> MCACHE["1. mcache<br>Локальный кэш без блокировок"]
MCACHE -->|"Нет пустых слотов"| MCENTRAL["2. mcentral<br>Кэш конкретного Size Class"]
MCENTRAL -->|"Нет пустых mspan"| MHEAP["3. mheap<br>Глобальная куча"]
MHEAP -->|"Память закончилась"| OS["Системный вызов<br>mmap/madvise"]
1. mcache (Уровень 1 — Локальный кэш)
Это высшее проявление Mechanical Sympathy в архитектуре аллокатора Go.
Как мы помним из главы 9. Scheduler Go. G, M, P и work stealing, рантайм моделирует вычислительные ресурсы через абстракцию P (Логический процессор), количество которых обычно равно числу физических ядер процессора (GOMAXPROCS).
К каждому экземпляру P намертво привязан один личный локальный кэш mcache.
Внутри mcache хранится массив активных указателей на mspan. На самом деле для каждого из 67 классов размеров в mcache выделено по два спана: один для объектов, содержащих указатели (scan), и один для объектов без указателей (noscan, например обычные слайсы байт или строки, которые GC не нужно сканировать). Итого $67 \times 2 = 134$ спана прямо под рукой.
Почему это работает мгновенно?
Поскольку в любой момент времени на одном логическом процессоре P выполняется ровно одна горутина (через поток ядра M), обращение к mcache не требует никаких мьютексов или CAS-инструкций. Выделение памяти сводится к элементарному вычислению индекса класса, извлечению свободного слота по битовой маске и сдвигу локального счетчика. Это занимает буквально 10–15 наносекунд. Ноль блокировок, ноль ожидания.
2. mcentral (Уровень 2 — Среднее звено)
Рано или поздно наступает момент, когда локальный mspan в mcache исчерпывает все свободные слоты. Если горутине требуется еще один объект на 24 байта, а в mcache для 3-го класса всё занято, логический процессор вынужден запросить свежий заполняемый спан.
На этом этапе в дело вступает координатор второго уровня — mcentral.
В рантайме существует ровно 67 центральных менеджеров mcentral — по одному на каждый класс размера (точнее, по паре для scan и noscan).
Каждый mcentral управляет двумя двусвязными списками спанов:
partial: список спановmspan, в которых есть хотя бы один свободный слот, пригодный для аллокаций.full: список полностью заполненных спанов (либо тех спанов, которые в данный момент переданы в эксклюзивное пользование какому-тоmcache).
Механика блокировок:
Поскольку за свежим спаном в mcentral может одновременно прийти несколько процессоров P, доступ к каждому mcentral защищен локальным спинлоком/мьютексом. Однако благодаря разделению на 67 изолированных классов конкуренция (Contention) практически отсутствует: поток, запрашивающий блок на 16 байт, обращается к mcentral[2] и ни на секунду не пересекается с потоком, запрашивающим память в mcentral[4] для объекта на 32 байта.
Когда mcache просит память, mcentral извлекает частично свободный mspan из списка partial, переносит его в список full и передает в локальное владение процессору P.
3. mheap (Уровень 3 — Глобальная куча)
Что происходит, если список partial в mcentral оказывается пуст и свободных спанов требуемого класса больше нет во всей системе? mcentral обращается к верховному распределителю ресурсов — mheap.
mheap — это единственный глобальный синглтон рантайма, представляющий собой всю виртуальную память программы.
mheapоперирует не мелкими классами размеров, а крупными непрерывными диапазонами физических страниц памяти (по 8 КБ).- Для мгновенного поиска свободных страниц рантайм использует специализированную структуру данных: ранее это было декартово дерево (
treap), а в современных версиях Go — компактное префиксное битовое дерево (Page Allocator Radix Tree), позволяющее за константное время находить смежные блоки нужного объема. - Если у
mheapесть свободные страницы, он нарезает запрошенный диапазон, оборачивает его в новый дескрипторmspan, инициализирует метаданные класса размера и возвращает его вmcentral. - Поскольку
mheapглобален для всей программы, доступ к его корневым таблицам защищен глобальным мьютексом (mheap.lock).
И только если глобальный mheap обнаруживает, что все ранее выделенные пулы страниц исчерпаны, он обращается к ядру операционной системы с системным вызовом (mmap на Linux/macOS или VirtualAlloc на Windows), резервируя новую виртуальную арену памяти размером в 64 МБ.
Итоговый алгоритм аллокации
Когда в коде выполняется выражение user := new(User) (и Escape Analysis доказал, что объект обязан отправиться в кучу), рантайм совершает каскадный путь:
- Вычисляется размер структуры
User(допустим, 36 байт). - Подбирается ближайший достаточный Size Class (для 36 байт это Класс 4 — слоты по 48 байт).
- Горутина обращается к своему текущему логическому процессору
Pи читаетmcache.alloc[ClassIndex]. - Fast Path: В локальном спане есть свободный слот. Аллокатор мгновенно забирает его по битовой маске. Блокировок нет, затраты минимальны.
- Slow Path 1: Локальный спан заполнен.
mcacheзахватывает мьютексmcentral[ClassIndex]и забирает свежийmspanсо свободными слотами из спискаpartial. - Slow Path 2: В
mcentralнет свободных спанов. Рантайм обращается кmheap, захватывает глобальный лок и запрашивает блок страниц нужной длины. - Slow Path 3: В
mheapзакончились физические страницы. Происходит системный вызовmmapк ядру ОС для выделения новой 64-мегабайтной арены.
Итог
- Аллокатор Go построен на проверенной архитектуре TCMalloc, минимизирующей блокировки и конкуренцию ядер за память.
- Память кучи нарезается на 67 классов размеров (Size Classes), что полностью побеждает внешнюю фрагментацию за счет контролируемой внутренней фрагментации (padding).
- mcache функционирует без блокировок, так как намертво закреплен за конкретным логическим процессором
P. - mcentral разделяет нагрузку по классам размеров, снижая вероятность межпоточных очередей.
- Объекты размером более 32 КБ классифицируются как Large Objects и выделяются напрямую из глобального
mheapпод глобальной блокировкой.
Мы детально исследовали путь обычных и крупных объектов. Но современный Go-сервис непрерывно оперирует миллиардами миниатюрных значений: булевыми флагами, однобайтными кодами ошибок, короткими строками и числами.
Если бы под каждый bool (1 байт) аллокатор выделял минимальный Size Class (8 байт), мы бы выбрасывали 87% оперативной памяти на ветер!
Чтобы исключить эту чудовищную неэффективность, архитекторы Go внедрили в рантайм еще один изящный микромеханизм. В следующей статье мы познакомимся с ним:
22. Tiny Allocator и маленькие объекты