18. mmap. Отображение файлов и памяти
Что такое mmap и зачем это нужно
«Самый быстрый ввод-вывод — тот, который вы вовсе не совершали. Следующий по скорости — доверить чтение аппаратному блоку MMU».
— Золотое правило высокопроизводительных систем хранения
Традиционный ввод-вывод в Unix через системные вызовы read() и write() напоминает заказ книги через курьера:
- Вы вызываете
read(fd, buf, len), и процессор совершает тяжелый переход из User Space в ядро (Ring 0). - Драйвер файловой системы считывает блок с диска в системный буфер ядра (Page Cache).
- Затем ядро физически копирует эти байты из памяти ядра в пользовательский буфер
bufвашей программы. - Процессор переключается обратно в 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:
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 не является серебряной пулей. Стоимость чтения байта зависит от того, где находится страница:
- Cache Hit (Страница в Page Cache): Прямое чтение из физической RAM со скоростью процессорного кэша: ~5–10 наносекунд.
- Minor Page Fault: Страница уже в памяти, но не привязана к PTE: ~200–500 наносекунд.
- 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 не существует.
Вопросы с собеседований
Итог
mmapпревращает файл на диске в плоский массив байт в памяти, исключая двойное копирование через системные вызовыread()иwrite().- Под капотом механизм опирается на структуры
vm_area_struct, страничный кэш ядра Linux (Page Cache) и аппаратные прерывания Page Fault. - Память
mmapнаходится вне кучи Go, не нагружая сборщик мусора GC, что делает ее идеальной базой для сверхбыстрых хранилищ данных (BoltDB, RocksDB, LMDB). - Остерегайтесь усечения файлов другими процессами: выход за реальные физические границы файла карается мгновенным падением процесса от сигнала
SIGBUS. - Для надежной фиксации данных на диске всегда используйте синхронизацию через
msync(MS_SYNC).
Мы досконально изучили отображение файлов и памяти. Но как эти виртуальные страницы складываются в общую анатомию процесса? Где заканчивается стек, где начинается куча и как они сосуществуют? В следующей статье мы разберем: 19. Сегментация памяти процесса. Stack, Heap, Data, Text.