18. mmap. Отображение файлов и памяти

Что такое mmap и зачем это нужно

«Самый быстрый ввод-вывод — тот, который вы вовсе не совершали. Следующий по скорости — доверить чтение аппаратному блоку MMU».
— Золотое правило высокопроизводительных систем хранения

Традиционный ввод-вывод в Unix через системные вызовы read() и write() напоминает заказ книги через курьера:

  1. Вы вызываете read(fd, buf, len), и процессор совершает тяжелый переход из User Space в ядро (Ring 0).
  2. Драйвер файловой системы считывает блок с диска в системный буфер ядра (Page Cache).
  3. Затем ядро физически копирует эти байты из памяти ядра в пользовательский буфер buf вашей программы.
  4. Процессор переключается обратно в Ring 3.

Для чтения миллиона записей вам придется совершить миллион системных вызовов и дважды скопировать каждый байт через шину памяти!

Механизм mmap (Memory-Mapped Files, отображение файлов в память) предлагает радикально иное решение. Он ломает стену между файлом на диске и памятью процесса: вместо копирования данных mmap отображает файл напрямую в виртуальное адресное пространство вашей программы.

Программа начинает работать с файлом на диске точно так же, как с обычным срезом байт в оперативной памяти: data[42] = 0xAA.

Для Senior/Lead бэкенд-инженера mmap — это фундамент уровня System Design:

  • Высоконагруженные встраиваемые базы данных: BoltDB (bbolt), LMDB, SQLite, RocksDB используют mmap для прямого O(1) чтения B-деревьев и WAL-журналов без выделения памяти в куче.
  • Межпроцессное взаимодействие (Zero-Copy IPC): Несколько независимых процессов могут отобразить один и тот же файл через флаг MAP_SHARED и обмениваться данными со скоростью оперативной памяти.
  • Обработка гигантских датасетов: Загрузка логов, индексных деревьев или моделей машинного обучения на 100 ГБ на сервере с 16 ГБ RAM без страха перед OOM.

Как mmap работает под капотом

Когда процесс вызывает системный вызов mmap(), ядро Linux не загружает файл в оперативную память немедленно.

Вместо этого ядро совершает быструю мета-операцию: оно выделяет структуру vm_area_struct (VMA) в виртуальном адресном пространстве процесса и привязывает ее к дескриптору открытого файла struct file. Таблица страниц (Page Table) заполняется пустыми записями с флагом Present = 0.

flowchart TD
    A["Go-процесс читает байт: data[4096]"] --> B["Аппаратный MMU процессора"]
    B -->|"Бит Present = 0"| C["Прерывание CPU: Page Fault (#14)"]
    C --> D["Ядро Linux (do_page_fault)"]
    D --> E{"Страница есть в Page Cache?"}
    E -- "Да (Minor Fault, ~200 нс)" --> F["Связать физический фрейм с PTE"]
    E -- "Нет (Major Fault, ~1-5 мс)" --> G["Чтение 4 КБ блока с NVMe/диска"]
    G --> H["Размещение в системном Page Cache RAM"]
    H --> F
    F --> I["Обновление Page Table: Present = 1"]
    I --> B
    B --> J["Прямой доступ к байту на кремниевой скорости"]

    classDef proc fill:#1e293b,stroke:#38bdf8,stroke-width:2px,color:#f8fafc;
    classDef fault fill:#4c0519,stroke:#f43f5e,stroke-width:2px,color:#ffe4e6;
    classDef ok fill:#064e3b,stroke:#10b981,stroke-width:2px,color:#ecfdf5;
    class A,B,J proc;
    class C,G fault;
    class E,F,H,I ok;
  

Идиоматичное использование в Go

В среде разработчиков Go нередко циркулирует заблуждение о существовании абстрактного метода os.File.Mmap. Однако стандартная библиотека языка Go (os.File) намеренно не содержит встроенного метода os.File.Mmap. Создатели языка посчитали этот механизм слишком платформенно-зависимым и вынесли его в низкоуровневые пакеты.

Для промышленной работы с mmap в Go используются системные пакеты syscall и golang.org/x/sys/unix, либо проверенные библиотеки уровня github.com/edsrzf/mmap-go:

Go
package main

import (
    "fmt"
    "log"
    "os"
    "syscall"
    "unsafe"
)

func main() {
    // 1. Создаем тестовый файл с данными
    f, err := os.CreateTemp("", "mmap-demo-*")
    if err != nil {
        log.Fatalf("create temp error: %v", err)
    }
    defer os.Remove(f.Name())
    defer f.Close()

    initialData := []byte("Hello, mmap under the hood! Zero-copy is awesome.")
    if _, err := f.Write(initialData); err != nil {
        log.Fatalf("write error: %v", err)
    }

    // 2. Отображаем файл в память через системный вызов
    // PROT_READ | PROT_WRITE: разрешаем чтение и запись
    // MAP_SHARED: изменения синхронизируются с файлом на диске и видны другим процессам
    mmapData, err := syscall.Mmap(
        int(f.Fd()),
        0,
        len(initialData),
        syscall.PROT_READ|syscall.PROT_WRITE,
        syscall.MAP_SHARED,
    )
    if err != nil {
        log.Fatalf("mmap error: %v", err)
    }
    defer func() {
        // Обязательно освобождаем отображение при завершении работы!
        if err := syscall.Munmap(mmapData); err != nil {
            log.Printf("munmap error: %v", err)
        }
    }()

    // 3. Читаем данные напрямую как срез байт ([]byte)
    fmt.Printf("Original content: %s\n", string(mmapData))

    // 4. Модифицируем файл напрямую через срез в памяти БЕЗ вызова write()!
    copy(mmapData[7:], []byte("world! Overhead: 0 syscalls."))

    fmt.Printf("Modified content: %s\n", string(mmapData))

    // 5. Принудительный сброс измененных dirty-страниц на диск (msync)
    // В фоне ядро сбрасывает страницы асинхронно через pdflush/flusher threads,
    // но для надежности баз данных (WAL, транзакции) вызов MS_SYNC обязателен!
    _, _, errno := syscall.Syscall(
        syscall.SYS_MSYNC,
        uintptr(unsafe.Pointer(&mmapData[0])),
        uintptr(len(mmapData)),
        uintptr(syscall.MS_SYNC),
    )
    if errno != 0 {
        log.Fatalf("msync failed: %v", errno)
    }
}

Механика и производительность (Mechanical Sympathy)

Page Faults и реальная стоимость доступа

mmap не является серебряной пулей. Стоимость чтения байта зависит от того, где находится страница:

  1. Cache Hit (Страница в Page Cache): Прямое чтение из физической RAM со скоростью процессорного кэша: ~5–10 наносекунд.
  2. Minor Page Fault: Страница уже в памяти, но не привязана к PTE: ~200–500 наносекунд.
  3. Major Page Fault: Данных в оперативной памяти нет. Поток блокируется в ядре, инициируя аппаратное чтение с накопителя: ~50–100 микросекунд на NVMe или ~5–10 миллисекунд на классическом HDD!

Если вы мапите файл размером 500 ГБ на машине с 16 ГБ RAM и начинаете хаотично читать случайные байты (Random Access), система утонет в Thrashing — процессор будет непрерывно простаивать в ожидании дисковых прерываний.

Влияние на процессорный кэш и TLB

  • mmap оперирует стандартной дискретностью в 4 КБ (или большими страницами hugepages по 2 МБ/1 ГБ при флаге MAP_HUGETLB).
  • Случайный доступ по огромному отображению мгновенно выбивает горячие строки из аппаратного кэша трансляций TLB. Потери от частых промахов TLB могут снизить общую производительность на 15–20%.

Взаимодействие со сборщиком мусора Go (GC)

Области памяти, полученные через mmap, находятся за пределами управляемой кучи Go (Heap): они не аллоцируются через внутренний аллокатор malloc / mallocgc рантайма Go и полностью исключены из дерева объектов GC.

  • Плюс: Сборщик мусора Go полностью игнорирует эти гигабайты памяти при сканировании корней, не создавая накладных расходов на фазы STW (Stop-The-World).
  • Минус: Рантайм Go не может автоматически освободить эту память. Если ваш сервис не вызовет Munmap, память будет удерживаться до завершения процесса.

Ловушки и граничные кейсы


Сравнение с read/write и альтернативы

Критерий Традиционные read() / write() Системный вызов mmap()
Количество системных вызовов Высокое (системный вызов на каждый буфер) Всего один вызов mmap и один munmap
Копирование данных в RAM Двойное: Диск $\o$ Page Cache $\o$ User Buffer Zero-Copy: Прямой доступ к Page Cache
Случайный доступ (Random Access) Катастрофически медленный (lseek + read) Идеальный: $O(1)$ по смещению указателя
Потребление оперативной памяти Строго контролируемый фиксированный буфер Эластичный Page Cache (риск Major Page Fault)
Нагрузка на Garbage Collector Go Создает аллокации буферов в куче 0% нагрузки на GC (память вне кучи Go)

Для последовательного потокового чтения огромных лог-файлов связка read() с пакетом bufio часто оказывается быстрее и безопаснее благодаря линейной предвыборке ОС (readahead). Но если вам требуется произвольный случайный доступ к терабайтной базе данных — альтернатив mmap в архитектуре POSIX не существует.


Вопросы с собеседований


Итог

  1. mmap превращает файл на диске в плоский массив байт в памяти, исключая двойное копирование через системные вызовы read() и write().
  2. Под капотом механизм опирается на структуры vm_area_struct, страничный кэш ядра Linux (Page Cache) и аппаратные прерывания Page Fault.
  3. Память mmap находится вне кучи Go, не нагружая сборщик мусора GC, что делает ее идеальной базой для сверхбыстрых хранилищ данных (BoltDB, RocksDB, LMDB).
  4. Остерегайтесь усечения файлов другими процессами: выход за реальные физические границы файла карается мгновенным падением процесса от сигнала SIGBUS.
  5. Для надежной фиксации данных на диске всегда используйте синхронизацию через msync(MS_SYNC).

Мы досконально изучили отображение файлов и памяти. Но как эти виртуальные страницы складываются в общую анатомию процесса? Где заканчивается стек, где начинается куча и как они сосуществуют? В следующей статье мы разберем: 19. Сегментация памяти процесса. Stack, Heap, Data, Text.