mmap — память как файл, файл как память
Когда вы читаете большой файл через read(), kernel сначала тащит страницы с диска в page cache, потом копирует их в ваш user-space буфер. Это два движения данных в RAM на каждое чтение — лишняя работа. mmap — альтернативный механизм: вы говорите kernel «возьми этот файл и сделай вид, что он лежит в моей памяти». Дальше обращаетесь к нему как к обычному массиву байт. Page cache используется напрямую, копирование исчезает.
Этот же механизм работает и наоборот: можно mmap без файла — получите анонимный регион памяти. Так под капотом работает malloc для больших аллокаций. И SharedMemory между процессами. И загрузчик динамических библиотек. mmap — это один из самых фундаментальных механизмов виртуальной памяти, который пронизывает всю систему.
Зачем mmap существует
Три класса задач, где mmap бьёт обычный I/O:
- Огромные файлы, к которым обращаешься рандомно. Базы данных (LMDB, SQLite), индексы (Lucene, RocksDB MemTable). Вместо
lseek + readу вас простоarray[offset]. - Zero-copy: пересылка файла в сокет / другой файл. mmap + write избегает копирования через user-space.
- 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.flags—MAP_SHARED(изменения видны всем + пишутся на диск) илиMAP_PRIVATE(copy-on-write, изменения только у меня).fd— file descriptor открытого файла.-1+MAP_ANONYMOUSдля анонимной памяти.
После успешного mmap у вас есть указатель p. Обращение p[1000] — это обращение к 1000-му байту файла. Никакого syscall не происходит — это обычный memory access.
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-only | write (mmap не любит расширение файла) |
| Нужна atomic write | write + 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);
}
Что физически происходит:
read: данные с диска -> page cache (DMA) -> ваш buf (copy в user-space).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);
Теперь:
mmap: ничего не копируется, просто настроены page tables.write: kernel читает из page cache (через ваш мэппинг) и копирует прямо в socket buffer.
Одно копирование вместо двух. Это и есть zero-copy (хотя строго одно копирование остаётся — из page cache в socket buffer).
Ещё лучше — sendfile() syscall: всё происходит в kernel, ни одного перехода через user space. Веб-сервера типа nginx используют именно sendfile. mmap — это «zero-copy без специального syscall».
Анонимный 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