15. malloc под капотом. Откуда ОС берет память

За кулисами malloc: от вызова функции до физической RAM

«Выделение памяти напоминает банковскую систему: ядро ОС выступает центробанком, аллокатор рантайма — местным филиалом, а ваше приложение — клиентом, снимающим двадцать долларов».
— Классическая метафора системного программирования

Когда вы пишете в своем Go-коде make([]byte, 1024) или используете оператор new(), создается обманчивое впечатление простоты: вы нажали кнопку банкомата, и память мгновенно оказалась в вашем распоряжении.

Но откуда банкомат взял купюры? Банкомат не печатает деньги на месте: инкассаторы привозят их из защищенного хранилища (ядра операционной системы) тяжелыми запечатанными пачками по 4 Килобайта (страницы памяти). А внутри самого банкомата трудится сложнейший механизм сортировки, кэширования и выдачи нужного номинала.

Разработчики часто считают malloc единой элементарной операцией. На самом деле перед нами классический «менеджер внутри менеджера» — трехуровневый конвейер:

  1. Аппаратный уровень: Чипы DRAM и блок процессора MMU.
  2. Уровень ядра ОС (Kernel Space): Системные вызовы brk и mmap, структуры vm_area_struct, страничный аллокатор ядра (Buddy Allocator) и кэши слябов (SLUB).
  3. Уровень пользовательского пространства (User Space): Аллокатор среды выполнения (в Си — glibc malloc, а в Go — собственный специализированный lock-free менеджер runtime.malloc).

Если бы на каждую созданную структуру приложение обращалось к ядру через системный вызов, сервер тратил бы 95% времени на переход в Ring 0. Давайте разберем, как устроена эта многослойная пирамида.


Уровень ОС: brk и mmap — два пути к памяти

Операционная система не раздает оперативную память байт за байтом: это привело бы к колоссальным накладным расходам на служебные дескрипторы. Минимальная неделимая единица, которой оперирует ядро Linux, — страница памяти (обычно 4 КБ на x86-64).

Когда приложению в пользовательском пространстве требуется расширить свои владения, у него есть ровно два фундаментальных системных вызова:

DIAGRAM
        ВАРИАНТ 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

  1. Arenas (Арены): Чтобы потоки не блокировали друг друга на общем мьютексе кучи, glibc создает независимые пулы памяти — арены (обычно до 8 x число ядер CPU).
  2. Chunks (Куски памяти): Вся память арены нарезана на чанки (chunks). Каждый чанк несет служебный заголовок: размер блока и битовые флаги (занят/свободен). Свободные чанки организуются в циклические двусвязные списки — free lists (fastbins, small bins, large bins, unsorted bin).
  3. TCache (Thread-Cache): Начиная с glibc 2.26, каждый системный поток получает собственный ультрабыстрый локальный кэш tcache на 64 класса размеров (для мелких блоков до ~1 КБ). Мелкие запросы обслуживаются мгновенно без захвата мьютексов арены.
  4. Арены и порог 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?

  1. Интеграция со сборщиком мусора (GC): Сборщику мусора Go необходимо точно знать типы данных и границы объектов. В glibc память «слепа»: это просто сырые байты. Go хранит подробные битовые карты (bitmap) указателей для каждого объекта.
  2. Борьба с фрагментацией: glibc неохотно отдает память ядру Linux. Go проектировался для облачных микросервисов, где важно динамически отдавать неиспользуемую память ядру через madvise, удерживая компактный RSS.
  3. Модель многопоточности M:N: glibc оптимизирует память под системные потоки ОС 1:1. В Go планировщик оперирует миллионами горутин поверх небольшого пула процессоров P. Локальный кэш памяти привязан не к системному треду, а к логическому процессору P, что обеспечивает чистый lock-free доступ!

Внутреннее устройство аллокатора Go

Аллокатор Go (созданный по мотивам архитектуры Google TCMalloc) делит память на строгую иерархию:

DIAGRAM
                      ГЛОБАЛЬНАЯ КУЧА mheap
         (Резервирует виртуальную память у ядра через mmap)
                                │
                 ┌──────────────┴──────────────┐
                 ▼                             ▼
         mcentral (Класс 1)            mcentral (Класс 2..68)
         (Центральные пулы spans)      (Пулы фиксированных размеров)
                 │                             │
         ┌───────┴───────┐             ┌───────┴───────┐
         ▼               ▼             ▼               ▼
    mcache (P0)     mcache (P1)   mcache (P2)     mcache (P3)
    (Без локов!)    (Без локов!)  (Без локов!)    (Без локов!)
  1. mheap: Глобальный синглтон рантайма, управляющий всей кучей процесса. Он запрашивает у ядра Linux виртуальные диапазоны аренами по 64 МБ через mmap и нарезает их на спаны (spans).
  2. mcentral: Коллекция spans для одного из 68 классов фиксированных размеров (Size Classes: от 8 байт до 32 КБ). Например, класс 1 хранит объекты по 8 байт, класс 2 — по 16 байт, класс 3 — по 24 байта и т.д.
  3. mcache: Локальный потокобезопасный кэш, привязанный к каждому логическому процессору P. Выделение малого объекта (например, make([]byte, 16)) происходит мгновенно из mcache без единого системного вызова и без блокировок мьютексов!
  4. span (или spans / mspan): Базовая единица аллокатора, состоящая из одной или нескольких смежных физических страниц ядра (обычно от 8 КБ до 64 КБ), нарезанных на блоки фиксированного размера.
  5. 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.

Итог

  1. malloc — это не системный вызов. Это сложный трехуровневый конвейер: прикладной код $\o$ пользовательский аллокатор $\o$ системные вызовы ядра (brk/mmap) $\o$ физический MMU.
  2. Go полностью обходит libc malloc. Собственный аллокатор Go (mheap $\o$ mcentral $\o$ mcache $\o$ span) основан на архитектуре TCMalloc и дает lock-free аллокацию для каждого процессора P.
  3. Эластичность RSS: Использование mmap в связке с madvise(ptr, size, MADV_DONTNEED) позволяет рантайму Go мгновенно возвращать физическую память ядру ОС без уничтожения виртуальных адресных пространств.
  4. Инженерный выбор: Для высоконагруженного бэкенда предсказуемость сборщика мусора и отсутствие блокировок в mcache неизмеримо важнее незначительного выигрыша brk на старте процесса.

Мы разобрались, как аллокаторы запрашивают память у ядра и почему mmap правит современным миром. Но что происходит, когда ядро порождает новый дочерний процесс через fork()? Неужели оно копирует гигабайты кучи родителя? В следующей статье мы разберем главный трюк виртуальной памяти: 16. Copy On Write. Почему fork не копирует всю память сразу.