15. malloc под капотом. Откуда ОС берет память
За кулисами malloc: от вызова функции до физической RAM
«Выделение памяти напоминает банковскую систему: ядро ОС выступает центробанком, аллокатор рантайма — местным филиалом, а ваше приложение — клиентом, снимающим двадцать долларов».
— Классическая метафора системного программирования
Когда вы пишете в своем Go-коде make([]byte, 1024) или используете оператор new(), создается обманчивое впечатление простоты: вы нажали кнопку банкомата, и память мгновенно оказалась в вашем распоряжении.
Но откуда банкомат взял купюры? Банкомат не печатает деньги на месте: инкассаторы привозят их из защищенного хранилища (ядра операционной системы) тяжелыми запечатанными пачками по 4 Килобайта (страницы памяти). А внутри самого банкомата трудится сложнейший механизм сортировки, кэширования и выдачи нужного номинала.
Разработчики часто считают malloc единой элементарной операцией. На самом деле перед нами классический «менеджер внутри менеджера» — трехуровневый конвейер:
- Аппаратный уровень: Чипы DRAM и блок процессора MMU.
- Уровень ядра ОС (Kernel Space): Системные вызовы
brkиmmap, структурыvm_area_struct, страничный аллокатор ядра (Buddy Allocator) и кэши слябов (SLUB). - Уровень пользовательского пространства (User Space): Аллокатор среды выполнения (в Си —
glibc malloc, а в Go — собственный специализированный lock-free менеджерruntime.malloc).
Если бы на каждую созданную структуру приложение обращалось к ядру через системный вызов, сервер тратил бы 95% времени на переход в Ring 0. Давайте разберем, как устроена эта многослойная пирамида.
Уровень ОС: brk и mmap — два пути к памяти
Операционная система не раздает оперативную память байт за байтом: это привело бы к колоссальным накладным расходам на служебные дескрипторы. Минимальная неделимая единица, которой оперирует ядро Linux, — страница памяти (обычно 4 КБ на x86-64).
Когда приложению в пользовательском пространстве требуется расширить свои владения, у него есть ровно два фундаментальных системных вызова:
ВАРИАНТ 1: Системный вызов brk() (Непрерывный стек кучи)
┌─────────────────────────────────────────────────────────────┐
│ Исполняемый код (.text) │ Данные (.data) │ Куча (Heap) │ <-- Program Break
└──────────────────────────────────────────┴─────────▲────────┘
│ brk() сдвигает вправо!
ВАРИАНТ 2: Системный вызов mmap() (Произвольные независимые арены)
┌──────────────────┐ ┌──────────────────┐ ┌──────────────────┐
│ Арена 1 (64 МБ) │ │ Стек потока │ │ Арена 2 (64 МБ) │
│ MAP_ANONYMOUS │ │ MAP_GROWSDOWN │ │ MAP_ANONYMOUS │
└──────────────────┘ └──────────────────┘ └──────────────────┘
1. brk / sbrk (Изменение предела сегмента данных)
Исторически первый способ управления кучей, появившийся еще в ранних версиях Unix.
У каждого процесса в структуре ядра есть указатель brk (Program Break), фиксирующий верхнюю границу сегмента неинициализированных данных (BSS).
- Как работает: Системный вызов
brk(new_addr)просто сдвигает этот указатель вверх. Память между старым и новым значением указателя становится доступной процессу. - Плюсы: Предельная простота. Вызов
brkчрезвычайно дешев на микроуровне, так как ядру достаточно изменить одно числовое поле в дескрипторе памяти. - Смертельный минус (Фрагментация): Память выделяется строго непрерывным блоком в одном направлении. Если вы аллоцировали 100 МБ через
brk, а затем освободили 99 МБ, оставив живым лишь один объект размером 16 байт на самой вершине, вызовfreeне может вернуть ядру ни одного байта! Память остается привязана к процессу, раздувая Resident Set Size (RSS). - Связь с Go: В современных ОС
brkиспользуется для выделения стека (mmapсMAP_GROWSDOWN) и для очень маленьких аллокаций в libc, но для больших буферов он считается устаревшим. Рантайм Go полностью игнорируетbrk.
2. mmap (Memory Mapping)
Современный промышленный стандарт для высокопроизводительных серверов. Для кучи используется в режиме создания анонимного отображения (MAP_ANONYMOUS | MAP_PRIVATE), не связанного с файлами на диске.
- Как работает: Ядро резервирует диапазон виртуальных адресов произвольного размера в любом свободном месте адресного пространства. Реальная физическая RAM выделяется лениво: при первом обращении процессор генерирует аппаратный
Page Fault, ядро привязывает физический 4 КБ фрейм и обновляет Page Table. - Плюсы: Никакой взаимной зависимости между блоками. Любой блок
mmapможет быть освобожден независимо от остальных через вызовmunmap. Поддерживает тонкое управление через системный вызовmadvise. - Минусы: Первое обращение к каждой странице требует обработки
Page Faultи обновления TLB, что чуть дорожеbrkна старте.
Уровень libc: glibc malloc — менеджер внутри менеджера
Когда C/C++ разработчик вызывает стандартную функцию malloc(), он не попадает в ядро Linux напрямую. Запрос перехватывает аллокатор стандартной библиотеки — glibc malloc (основанный на знаменитом алгоритме ptmalloc, потомке dlmalloc).
Архитектура glibc malloc
- Arenas (Арены): Чтобы потоки не блокировали друг друга на общем мьютексе кучи, glibc создает независимые пулы памяти — арены (обычно до 8 x число ядер CPU).
- Chunks (Куски памяти): Вся память арены нарезана на чанки (
chunks). Каждый чанк несет служебный заголовок: размер блока и битовые флаги (занят/свободен). Свободные чанки организуются в циклические двусвязные списки —free lists(fastbins, small bins, large bins, unsorted bin). - TCache (Thread-Cache): Начиная с glibc 2.26, каждый системный поток получает собственный ультрабыстрый локальный кэш
tcacheна 64 класса размеров (для мелких блоков до ~1 КБ). Мелкие запросы обслуживаются мгновенно без захвата мьютексов арены. - Арены и порог mmap: Запросы среднего размера (до 128 КБ) обслуживаются списками арены (fastbins, smallbins, largebins), пополняемыми через
brk(или суб-арены). Если запрашиваемый размер превышает порогmmap_threshold(по умолчанию от 128 КБ),mallocвыделяет память напрямую у ядра через системный вызовmmap.
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
App["Приложение (C / C++)"]:::process -->|"malloc"| Libc["glibc malloc (ptmalloc)"]
Libc -->|"До ~1032 байт"| Tcache["TCache
Локальный кэш потока"]
Tcache -.->|"Если пуст"| Arena["Арена потока (bins)"]
Libc -->|"До 128KB"| Arena
Arena -->|"Рост кучи арены"| Brk["Системный вызов brk / sbrk"]
Libc -->|"Свыше 128KB (mmap threshold)"| Mmap["Системный вызов mmap
(Анонимное отображение)"]
Mmap --> PageTable["Ядро Linux:
Таблица страниц и VMA"]
Brk --> PageTable
Уровень Go: Собственный аллокатор и обход libc
Язык Go выбрал радикальный путь: рантайм Go намеренно не использует libc malloc для управления своей кучей!
Когда вы вызываете make(), управление передается внутренней функции runtime.malloc / runtime.mallocgc (подробно весь цикл взаимодействия мы рассмотрим в 62. Как Go runtime взаимодействует с ОС).
Почему Go отказался от libc malloc?
- Интеграция со сборщиком мусора (GC): Сборщику мусора Go необходимо точно знать типы данных и границы объектов. В glibc память «слепа»: это просто сырые байты. Go хранит подробные битовые карты (
bitmap) указателей для каждого объекта. - Борьба с фрагментацией: glibc неохотно отдает память ядру Linux. Go проектировался для облачных микросервисов, где важно динамически отдавать неиспользуемую память ядру через
madvise, удерживая компактный RSS. - Модель многопоточности M:N: glibc оптимизирует память под системные потоки ОС 1:1. В Go планировщик оперирует миллионами горутин поверх небольшого пула процессоров
P. Локальный кэш памяти привязан не к системному треду, а к логическому процессоруP, что обеспечивает чистый lock-free доступ!
Внутреннее устройство аллокатора Go
Аллокатор Go (созданный по мотивам архитектуры Google TCMalloc) делит память на строгую иерархию:
ГЛОБАЛЬНАЯ КУЧА mheap
(Резервирует виртуальную память у ядра через mmap)
│
┌──────────────┴──────────────┐
▼ ▼
mcentral (Класс 1) mcentral (Класс 2..68)
(Центральные пулы spans) (Пулы фиксированных размеров)
│ │
┌───────┴───────┐ ┌───────┴───────┐
▼ ▼ ▼ ▼
mcache (P0) mcache (P1) mcache (P2) mcache (P3)
(Без локов!) (Без локов!) (Без локов!) (Без локов!)
mheap: Глобальный синглтон рантайма, управляющий всей кучей процесса. Он запрашивает у ядра Linux виртуальные диапазоны аренами по 64 МБ черезmmapи нарезает их на спаны (spans).mcentral: Коллекцияspansдля одного из 68 классов фиксированных размеров (Size Classes: от 8 байт до 32 КБ). Например, класс 1 хранит объекты по 8 байт, класс 2 — по 16 байт, класс 3 — по 24 байта и т.д.mcache: Локальный потокобезопасный кэш, привязанный к каждому логическому процессоруP. Выделение малого объекта (например,make([]byte, 16)) происходит мгновенно изmcacheбез единого системного вызова и без блокировок мьютексов!span(илиspans/mspan): Базовая единица аллокатора, состоящая из одной или нескольких смежных физических страниц ядра (обычно от 8 КБ до 64 КБ), нарезанных на блоки фиксированного размера.bitmap: Для каждого спана рантайм ведет битовую карту указателей (bitmap), сообщающую GC, какие слова в объекте являются указателями на кучу, а какие — скалярами.
Mechanical Sympathy: Кэш, TLB и фрагментация
Кэш-линии и выравнивание
Современный процессор обращается к памяти строками по 64 байта (L1 Cache Line). Если два потока на разных ядрах непрерывно пишут в независимые переменные, случайно оказавшиеся внутри одной 64-байтной строки, возникает аппаратная катастрофа — False Sharing (ложное разделение). Процессор вынужден непрерывно гонять строку между кэшами ядер по шине когерентности MESI. Аллокатор Go сам этого не гарантирует: его размерные классы (8, 16, 24, 32, 48, 64, 80, 96, 112… байт) не все кратны 64, и два небольших объекта, выделенных разным горутинам, вполне могут оказаться в одной строке кэша. От False Sharing защищаются явно, выравнивающим заполнением (padding) горячих полей до размера строки кэша.
TLB и стоимость mmap
Каждая новая страница, задействованная через mmap, требует записи в таблицы страниц ядра и кэш TLB (Translation Lookaside Buffer).
Если система поддерживает большие страницы (Huge Pages по 2 МБ), рантайм Go может подсказать ядру Linux использовать их с помощью системного вызова madvise(MADV_HUGEPAGE). Это сокращает число необходимых записей в TLB в 512 раз и полностью ликвидирует задержки на Page Walk.
madvise и давление на GC
Когда сборщик мусора Go завершает фазу очистки, он не возвращает память ОС через munmap (это разрушило бы виртуальные арены).
Если память не востребована и параметр GOGC или мягкий лимит GOMEMLIMIT требуют сжатия кучи, фоновый скавенджер Go вызывает madvise(ptr, size, MADV_DONTNEED) на неиспользуемых страницах кучи:
- Что делает ядро Linux: Ядро мгновенно отвязывает физические страницы RAM от виртуального адреса и сбрасывает счетчик RSS процесса, но сохраняет запись в таблице страниц!
- Что происходит при следующем обращении: Когда горутине снова потребуется эта память, процессор генерирует аппаратный
Page Fault, и ядро выделяет свежий чистый фрейм. Это в сотни раз дешевле, чем заново выполнятьmunmapиmmap.
Итог
malloc— это не системный вызов. Это сложный трехуровневый конвейер: прикладной код $\o$ пользовательский аллокатор $\o$ системные вызовы ядра (brk/mmap) $\o$ физический MMU.- Go полностью обходит libc
malloc. Собственный аллокатор Go (mheap$\o$mcentral$\o$mcache$\o$span) основан на архитектуре TCMalloc и дает lock-free аллокацию для каждого процессораP. - Эластичность RSS: Использование
mmapв связке сmadvise(ptr, size, MADV_DONTNEED)позволяет рантайму Go мгновенно возвращать физическую память ядру ОС без уничтожения виртуальных адресных пространств. - Инженерный выбор: Для высоконагруженного бэкенда предсказуемость сборщика мусора и отсутствие блокировок в
mcacheнеизмеримо важнее незначительного выигрышаbrkна старте процесса.
Мы разобрались, как аллокаторы запрашивают память у ядра и почему mmap правит современным миром. Но что происходит, когда ядро порождает новый дочерний процесс через fork()? Неужели оно копирует гигабайты кучи родителя? В следующей статье мы разберем главный трюк виртуальной памяти: 16. Copy On Write. Почему fork не копирует всю память сразу.