Learning Platform
Глоссарий Troubleshooting
Урок 11.04 · 22 мин
Средний
mmapVirtual MemoryZero-CopyPage CachePerformance

mmap — память как файл, файл как память

Когда вы читаете большой файл через read(), kernel сначала тащит страницы с диска в page cache, потом копирует их в ваш user-space буфер. Это два движения данных в RAM на каждое чтение — лишняя работа. mmap — альтернативный механизм: вы говорите kernel «возьми этот файл и сделай вид, что он лежит в моей памяти». Дальше обращаетесь к нему как к обычному массиву байт. Page cache используется напрямую, копирование исчезает.

Этот же механизм работает и наоборот: можно mmap без файла — получите анонимный регион памяти. Так под капотом работает malloc для больших аллокаций. И SharedMemory между процессами. И загрузчик динамических библиотек. mmap — это один из самых фундаментальных механизмов виртуальной памяти, который пронизывает всю систему.


Зачем mmap существует

Три класса задач, где mmap бьёт обычный I/O:

  1. Огромные файлы, к которым обращаешься рандомно. Базы данных (LMDB, SQLite), индексы (Lucene, RocksDB MemTable). Вместо lseek + read у вас просто array[offset].
  2. Zero-copy: пересылка файла в сокет / другой файл. mmap + write избегает копирования через user-space.
  3. Shared memory. Два процесса делят файл в RAM, видят изменения друг друга. Это основа IPC.

И четвёртое, не менее важное: malloc для больших блоков использует mmap внутри. Если вы выделяете 10 MB в C/Python — скорее всего, это анонимный mmap, не brk-расширение heap.


Как работает mmap (концепция)

mmap создаёт отображение между регионом вашего виртуального адресного пространства и страницами файла (или анонимной памятью). Само по себе это не загружает данные — просто настраивает page table.

void* mmap(void* addr, size_t length, int prot, int flags, int fd, off_t offset);
  • length — сколько байт отобразить (кратно странице, обычно 4096).
  • prot — права: PROT_READ, PROT_WRITE, PROT_EXEC.
  • flagsMAP_SHARED (изменения видны всем + пишутся на диск) или MAP_PRIVATE (copy-on-write, изменения только у меня).
  • fd — file descriptor открытого файла. -1 + MAP_ANONYMOUS для анонимной памяти.

После успешного mmap у вас есть указатель p. Обращение p[1000] — это обращение к 1000-му байту файла. Никакого syscall не происходит — это обычный memory access.

mmap: связь виртуальной памяти процесса со страницами файла
Virtual memoryВиртуальное адресное пространство процесса. После mmap здесь есть регион, который ссылается на файл
page table
Page cacheТе же страницы page cache, что использует обычный read/write. mmap не делает копии в user space
Disk fileФайл на диске. Загружается в page cache по требованию (page fault)
page fault
Lazy loadingПри первом обращении к странице происходит page fault, kernel загружает её с диска в page cache. До этого -- кеш пуст

Lazy loading — ключевое свойство. Вы можете mmap гигабайтный файл за микросекунды. Загружаться будут только те страницы, к которым вы реально обращаетесь.


Простой пример в Python

import mmap

# Открыть файл и создать mmap:
with open('/etc/passwd', 'rb') as f:
    # Длина 0 = весь файл
    mm = mmap.mmap(f.fileno(), 0, prot=mmap.PROT_READ)

    # Теперь mm ведёт себя как bytes:
    print(mm[:50])               # первые 50 байт
    print(mm.find(b'root'))      # поиск
    print(len(mm))               # размер файла

    mm.close()

Особенно красиво для поиска по большому логу:

with open('/var/log/syslog', 'rb') as f:
    mm = mmap.mmap(f.fileno(), 0, prot=mmap.PROT_READ)
    # Найти все вхождения 'error':
    pos = 0
    while True:
        pos = mm.find(b'error', pos)
        if pos == -1: break
        # Прочитать строку вокруг:
        line_start = mm.rfind(b'\n', 0, pos) + 1
        line_end = mm.find(b'\n', pos)
        print(mm[line_start:line_end].decode())
        pos = line_end
    mm.close()

Для файла 10 GB это работает, не загружая всё в RAM — kernel загружает только нужные страницы по page fault.


mmap vs read: когда что выигрывает

СценарийВыигрывает
Sequential read большого файла один разread (предсказуемый, prefetch работает идеально)
Random access к большому файлуmmap (нет копирования, лениво)
Файл часто переиспользуется (горячие данные)mmap (нет дублирования в user buffer)
Маленький файл, читаем один разread (overhead mmap не оправдан)
Пишем в файл append-onlywrite (mmap не любит расширение файла)
Нужна atomic writewrite + fsync + rename (mmap+msync сложнее)
Hex viewer, поиск по файлуmmap (random access идеален)

«Mmap всегда быстрее read» — мем. На современных ядрах с readahead для sequential workload обычный read часто не хуже, иногда лучше. Реальный выигрыш mmap — random access и shared memory.


MAP_SHARED vs MAP_PRIVATE

Два разных режима с принципиально разной семантикой:

MAP_SHARED: ваши изменения видны другим процессам, которые тоже сделали mmap этого файла. И записываются на диск (через page cache + writeback). Это «настоящий» файл в памяти.

MAP_PRIVATE: copy-on-write. До первой записи вы делите страницы с page cache. При записи kernel создаёт ваш копию страницы. Изменения не видны другим, на диск не пишутся. Это удобно для загрузки read-only части (например, секций бинарника), которые можно изменять только в памяти.

import mmap

# Shared: пишем в файл через память:
with open('/tmp/shared.dat', 'r+b') as f:
    mm = mmap.mmap(f.fileno(), 100, prot=mmap.PROT_READ | mmap.PROT_WRITE)
    mm[0:5] = b'hello'   # это запись в файл!
    mm.flush()           # эквивалент msync -- сбросить на диск
    mm.close()

# После закрытия в /tmp/shared.dat есть 'hello' в начале

Zero-copy: классический use-case

Веб-сервер хочет отдать клиенту 100 MB файл. Наивный подход:

char buf[4096];
ssize_t n;
while ((n = read(file_fd, buf, sizeof(buf))) > 0) {
    write(sock_fd, buf, n);
}

Что физически происходит:

  1. read: данные с диска -> page cache (DMA) -> ваш buf (copy в user-space).
  2. write: ваш buf -> kernel socket buffer (copy в kernel) -> сеть (DMA).

Для 100 MB это 200 MB копирования в RAM (один read — 100 MB, один write — 100 MB).

С mmap:

void* p = mmap(NULL, size, PROT_READ, MAP_PRIVATE, file_fd, 0);
write(sock_fd, p, size);

Теперь:

  1. mmap: ничего не копируется, просто настроены page tables.
  2. write: kernel читает из page cache (через ваш мэппинг) и копирует прямо в socket buffer.

Одно копирование вместо двух. Это и есть zero-copy (хотя строго одно копирование остаётся — из page cache в socket buffer).

Ещё лучше — sendfile() syscall: всё происходит в kernel, ни одного перехода через user space. Веб-сервера типа nginx используют именно sendfile. mmap — это «zero-copy без специального syscall».

Berkeley sockets API: socket buffer — конечная точка zero-copy
Web-server: read+write vs mmap+write vs sendfile
read+writeДва copy в RAM: page cache -> user buf, user buf -> socket buf. Два syscall. Просто но медленно
mmap+writeОдно copy: page cache -> socket buf. Mmap не копирует, write читает прямо из page cache
sendfile()Один syscall, без mmap. Kernel сам переливает из page cache в socket. Используется в nginx, kafka. Один copy в RAM, один syscall
splice / DMAСовсем без копирования в RAM. Зависит от поддержки железом. Идеал, доступен не везде

Анонимный mmap: основа malloc

Если вызвать mmap с MAP_ANONYMOUS (или fd=-1), вы получите кусок памяти, не связанный с файлом. Изначально заполнен нулями. Освобождается через munmap.

void* p = mmap(NULL, 1024*1024, PROT_READ | PROT_WRITE,
               MAP_PRIVATE | MAP_ANONYMOUS, -1, 0);
// 1 MB обнулённой памяти
munmap(p, 1024*1024);

Glibc malloc для аллокаций больше MMAP_THRESHOLD (по умолчанию 128 KB) использует именно это вместо расширения heap через brk. Преимущества:

  • Можно вернуть память обратно kernel через munmap (heap через brk так не умеет, он только растёт).
  • Каждый mmap независим, фрагментация меньше.
# Посмотреть, что mmap'нул процесс:
cat /proc/self/maps
# Или для конкретного процесса:
cat /proc/$(pgrep -f firefox | head -1)/maps | head -20
# 5630a3c00000-5630a3c01000 r--p 00000000 fd:00 1234567  /usr/bin/firefox
# 7f2a1c000000-7f2a1c021000 rw-p 00000000 00:00 0        (анонимный, heap-like)
# ...

# Видеть в реальном времени:
sudo strace -e mmap,munmap,brk -p $(pidof firefox) 2>&1 | head -20

Гранулярность: страницы

mmap всегда работает в гранулярности страниц памяти — обычно 4096 байт (узнать: getconf PAGESIZE). Это значит:

  • Размер mmap округляется вверх до страницы.
  • Защита (PROT_*) и flags применяются на уровне страниц.
  • Дискретность page fault’ов — 1 страница.

Page fault при первом обращении дешёв (~1 мкс), но если у вас файл из миллиона страниц и random access по нему — может быть миллион page faults. Иногда madvise(MADV_WILLNEED) помогает: подсказать kernel «эти страницы скоро понадобятся, читай заранее».

// Эти данные нужно держать в RAM:
madvise(p, size, MADV_WILLNEED);

// Этот регион пока не нужен, можешь освободить:
madvise(p, size, MADV_DONTNEED);

// Будем читать sequential -- настройся на префетч:
madvise(p, size, MADV_SEQUENTIAL);

Где mmap ломается / сюрпризы

SIGBUS вместо EOF. Если файл сократился (truncate), а вы обратились к страницам за новый конец — SIGBUS, не EOF. Mmap не «понимает» конец файла, только размер мэппинга.

Файл не растёт автоматически. Mmap на 1 MB останется 1 MB, даже если потом write добавил байты в файл. Нужно mremap или новый mmap.

Память считается «используемой». RSS процесса растёт по мере touch’a страниц. На системах с overcommit это может убить процесс через OOM.

Не работает на сетевых FS (NFS) надёжно. Поведение зависит от реализации. Не делайте production-зависимости от mmap’а NFS-файла.

msync нужен для durability. Запись через mmap идёт в page cache. Чтобы гарантировать диск, нужен msync(addr, len, MS_SYNC) — эквивалент fsync для mmap.

memcpy(p + 100, data, 50);  // запись в файл (через page cache)
msync(p, 4096, MS_SYNC);    // эквивалент fsync для этой страницы

Реальный пример: LMDB

LMDB (Lightning Memory-Mapped Database) — KV store, который вся «база» — это один mmap’нутый файл. Чтения — просто memory access, кеш файла полностью совпадает с page cache, нет своего buffer pool. Запись — через msync. Получаются:

  • Чтения почти бесплатные (memory speed, нет syscall).
  • Crash safety — LMDB использует MVCC + atomic page swap.
  • Простота: ~10K строк C-кода.

Используется в OpenLDAP, Caddy (sessions), MailDir, Mozilla Firefox. Хорошо показывает, на что способен mmap, если правильно его использовать.


Попробуй сам

# 1. Размер страницы вашей системы:
getconf PAGESIZE   # обычно 4096

# 2. Посмотреть, какие регионы mmap'нул shell:
cat /proc/$$/maps | head -20
# r--p -- read-only private (типичный текст бинарника)
# rw-p -- read-write private (heap, stack)
# r-xp -- read+execute (код)

# 3. Создать большой файл и mmap'нуть:
dd if=/dev/zero of=/tmp/big.dat bs=1M count=100
python3 -c "
import mmap
with open('/tmp/big.dat', 'r+b') as f:
    mm = mmap.mmap(f.fileno(), 0)
    # Поиграть с random access:
    mm[50_000_000:50_000_010] = b'hello here'
    mm.flush()
    print(mm[50_000_000:50_000_020])
    mm.close()
"

# 4. Сравнить скорость поиска через mmap и обычное чтение:
# (на большом файле)
time python3 -c "
data = open('/tmp/big.dat', 'rb').read()
print(data.count(b'\x00'))
"

time python3 -c "
import mmap
with open('/tmp/big.dat', 'rb') as f:
    mm = mmap.mmap(f.fileno(), 0, prot=mmap.PROT_READ)
    print(mm.count(b'\x00'))
    mm.close()
"

# 5. Посмотреть, как glibc использует mmap для больших malloc:
strace -e mmap python3 -c "x = bytes(1024*1024)" 2>&1 | grep mmap

Проверка знанийKnowledge check
У вас 50 GB log-файлов на одном сервере. Нужно реализовать grep с поддержкой regex и быстрого random access (jump к конкретному offset). Какой подход выбрать: read в цикле, mmap, или sendfile? Почему?
ОтветAnswer
Для этой задачи mmap -- правильный выбор, и вот почему. 50 GB не помещается в RAM, но через mmap это не проблема: kernel загружает страницы по требованию (on-demand paging), мы не пытаемся прочитать всё разом. Виртуальная память процесса вырастет на 50 GB, но RSS (реально используемая RAM) останется маленьким -- столько, к скольким страницам мы реально обращались. Random access -- сильная сторона mmap. file[offset] это просто memory access, никакого lseek+read. Если grep'ит сначала тут, потом там -- mmap идеален. С read нужно делать lseek (syscall), потом read (syscall) -- два syscall на каждый jump. Regex по mmap-региону работает естественно: библиотеки regex принимают указатель + размер, mmap даёт ровно это. Без копирования в свой буфер. Sendfile здесь не подходит -- это для пересылки файла в сокет/другой fd целиком. У нас задача -- читать и парсить, а не передавать. read в цикле работает, но проигрывает: каждый read = два копирования (диск -> page cache -> наш буфер). При random access ещё и lseek-ы. Совет на практике: madvise(p, size, MADV_SEQUENTIAL) если grep линейный (включит readahead), MADV_RANDOM если jump-ы (выключит readahead, чтобы не тратить RAM на ненужное). MADV_WILLNEED если мы скоро будем читать какой-то регион -- kernel начнёт fetch в фоне. Что важно проверить заранее: 50 GB файл на NFS или ext4? mmap на NFS работает нестабильно. На локальном ext4/xfs -- идеально. Конкретно ripgrep, ag, GNU grep используют mmap для больших файлов именно по этой причине.

Проверьте понимание

Результат: 0 из 0
Концептуальный
Вопрос 1 из 6. Что физически делает mmap, когда вы отображаете 10 GB файл?

Закончили урок?

Отметьте его как пройденный, чтобы отслеживать свой прогресс

Войдите чтобы оценить урок

Прогресс модуля
0 из 4