Что такое mmap и почему файл можно читать как обычную переменную
Обычно доступ к файлу — это цепочка вызовов open(), read() в заранее выделенный буфер, обработка ошибок, close(). Но есть способ, при котором файл просто появляется в адресном пространстве процесса как обычный массив байт: buf[100500] читает данные из файла, а присваивание buf[100500] = 1 их туда пишет — без единого явного вызова read() или write(). Это и есть mmap(), и за этой простотой на уровне кода стоит довольно нетривиальная механика ядра, которую полезно понимать, прежде чем полагаться на неё в проде.
Содержание
Что делает mmap на самом деле
Системный вызов mmap() не копирует файл в память. Он создаёт отображение (mapping) — договорённость между процессом и ядром о том, что определённый диапазон виртуальных адресов процесса соответствует определённому диапазону байт файла на диске:
void *addr = mmap(NULL, length, PROT_READ | PROT_WRITE, MAP_SHARED, fd, offset);
После этого вызова addr — обычный указатель. Но в момент вызова физически в память ничего не загружается: ядро только резервирует область виртуального адресного пространства и заводит для неё запись в таблице отображений процесса (VMA, virtual memory area). Страницы физической памяти появляются лениво, по одной, только когда процесс реально к ним обращается.
Это принципиально отличается от модели read(). При read() вы явно говорите ядру: скопируй N байт файла в этот буфер прямо сейчас. При mmap() вы говорите: пусть этот диапазаон памяти *представляет* файл, а когда мне понадобятся конкретные байты — подгрузи их сам.
Механизм: page fault и подгрузка по требованию
Когда процесс впервые обращается к адресу внутри замапленной области, происходит page fault — аппаратное прерывание процессора, который не находит эту виртуальную страницу в своей таблице трансляции адресов (TLB/page table). Дальше управление получает обработчик page fault в ядре, и для файлового отображения он делает следующее:
- Смотрит, какому смещению файла соответствует запрошенная страница.
- Проверяет, есть ли эта страница уже в page cache — общем для всей системы кеше страниц файлов в оперативной памяти.
- Если страницы в кеше нет — читает нужный блок с диска в page cache (это уже обычная блочная операция ввода-вывода, со своей задержкой).
- Прописывает в таблице страниц процесса отображение: эта виртуальная страница указывает на эту физическую страницу page cache.
- Возвращает управление процессу, который повторяет прерванную инструкцию — и на этот раз она успешно читает или пишет данные, уже находясь в памяти.
Ключевой момент здесь — mmap не заводит отдельную копию данных. Замапленная область указывает напрямую на страницы page cache, того же самого кеша, которым ядро пользуется и при обычных read()/write() на этом же файле. Именно поэтому два процесса, замапивших один и тот же файл через MAP_SHARED, физически делят одни и те же страницы памяти: изменение, сделанное одним, немедленно видно другому — без какого-либо межпроцессного взаимодействия, просто потому что это буквально одна и та же память.
Про то, как устроен этот общий page cache и почему запись в него не означает мгновенную запись на диск, подробно разобрано в статье про файловый кеш и грязные страницы — механика writeback там та же самая, mmap просто даёт прямой доступ к тем же страницам в обход read/write.
Нужен сервер под эту задачу?
Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации UK, США, Франция и РФ. Оплата картой РФ и по СБП.
Арендовать серверПочему это быстрее: одна копия вместо двух
Классический путь read() требует минимум одного копирования данных сверх того, что происходит при mmap. Возьмём чтение файла в буфер приложения:
- Ядро читает блок с диска в page cache (это происходит в обоих случаях — от диска никуда не деться).
- При
read()ядро дополнительно копирует данные из page cache в буфер пользовательского процесса, который вы передали вызову. - При
mmap()этого второго копирования нет: приложение читает данные прямо из page cache, обращаясь к ним как к своей памяти.
Для одного маленького файла разница не заметна. Но если приложение много раз читает большие объёмы данных из файла — например, парсер логов, движок полнотекстового поиска или сервис, отдающий статику, — устранение лишнего копирования снижает и нагрузку на CPU (меньше memcpy), и пиковое потребление памяти (не нужен отдельный буфер размером с читаемый кусок файла). Насколько именно это заметно в конкретном случае — зависит от паттерна доступа, размера файла и того, что вообще делает приложение с данными дальше; универсальной цифры выигрыша тут не существует, и не стоит доверять источникам, которые её называют без контекста.
Второе практическое преимущество — работа с файлами, которые больше доступной оперативной памяти. С mmap можно замапить файл на 200 ГБ на сервере с 32 ГБ RAM одним вызовом, и адресное пространство процесса это позволит (виртуальная память современных систем на порядки больше физической). Физически в память при этом попадут только те страницы, к которым реально было обращение — а ненужные части файла ядро вытеснит из page cache по обычной политике LRU, если понадобится место для более «горячих» страниц.
Выгоды: где mmap реально применяют
Базы данных. Многие СУБД и встраиваемые хранилища используют mmap для доступа к файлам данных или индексов — вместо того чтобы вручную реализовывать собственное кеширование блоков поверх read()/write(), они перекладывают эту работу на page cache ядра. SQLite умеет работать в режиме mmap_size, LMDB построена вокруг mmap как основного механизма доступа к данным целиком. Выигрыш двойной: не тратится память на дублирование буферов приложения поверх page cache, и не нужно писать код, который заново изобретает вытеснение страниц — ядро уже умеет это делать.
Загрузка больших датасетов. В задачах, где нужно работать с файлом данных (снимок индекса, векторное хранилище, файл модели), который заведомо больше объёма RAM, mmap позволяет открыть файл как единый массив и обращаться к произвольным его частям, не думая о ручном постраничном чтении. Ядро само решит, какие страницы держать в памяти, а какие вытеснить — исходя из реального паттерна обращений, а не из догадок, заложенных в код приложения заранее.
Разделяемая память между процессами. mmap() с флагом MAP_SHARED над одним файлом (или над анонимной областью с флагом MAP_ANONYMOUS) — стандартный способ организовать быструю разделяемую память между несколькими процессами на одной машине, без сериализации через сокеты или пайпы.
Загрузка исполняемых файлов и библиотек. Когда вы запускаете программу, ядро само использует mmap, чтобы отобразить исполняемый файл и подключаемые .so/.dll-библиотеки в память процесса. Именно поэтому запуск программы не требует прочитать весь бинарник целиком заранее — подгружаются только те страницы кода, которые реально выполняются.
Подводный камень №1: изменения видны всем сразу
С MAP_SHARED любое изменение данных через указатель немедленно видно всем процессам, замапившим тот же файл — потому что это в буквальном смысле одна физическая память, а не копии с последующей синхронизацией. Для многопоточного или межпроцессного взаимодействия это иногда ровно то, что нужно. Но если вы ожидали изоляции — например, писали код по аналогии с обычным файловым буфером, который принадлежит только вам, — это источник неочевидных гонок.
Отдельно стоит отметить флаг MAP_PRIVATE: он создаёт copy-on-write отображение — при попытке записи ядро создаёт приватную копию именно той страницы, в которую пишут, и дальнейшие изменения этой копии не видны ни другим процессам, ни исходному файлу на диске. Это то, что использует, например, загрузчик исполняемых файлов для сегмента данных с глобальными переменными — каждый процесс должен иметь свою независимую копию, а не делить её с другими запущенными копиями той же программы.
Путать MAP_SHARED и MAP_PRIVATE — частая ошибка: код, который должен был писать в приватную рабочую копию, случайно оказывается замапленным как shared, и изменения улетают в файл на диске раньше, чем ожидалось, либо утекают в другой процесс.
Подводный камень №2: ошибки ввода-вывода превращаются в сигналы
Это самое неприятное отличие mmap от обычного чтения файлов с точки зрения обработки ошибок. Вызов read() возвращает код ошибки синхронно и предсказуемо — вы проверяете возвращаемое значение и решаете, что делать. С mmap ситуация другая: обращение к памяти — это не вызов функции, а просто чтение или запись по указателю, и у процессора нет способа вернуть код ошибки из инструкции mov.
Если во время подгрузки страницы (в обработчике page fault) происходит ошибка ввода-вывода — диск вернул I/O error, файл на сетевой файловой системе стал недоступен, либо процесс обратился за пределы реального размера файла в замапленной области — ядро не может «вернуть -1» из операции чтения памяти. Вместо этого оно посылает процессу сигнал SIGBUS (bus error). Если приложение не установило обработчик этого сигнала, процесс просто аварийно завершается в неожиданный момент — в строке кода, которая выглядит как безобидное x = buf[i].
Это принципиально меняет модель обработки ошибок: код, работающий с mmap-областью, должен либо ставить обработчик SIGBUS (что само по себе не тривиально — из обработчика сигнала нельзя безопасно продолжить выполнение исходной инструкции без специальных ухищрений), либо заранее исключать ситуации, в которых I/O-ошибка вообще может возникнуть в момент доступа — например, гарантированно не обращаться за пределы актуального размера файла, если он может быть урезан другим процессом параллельно.
Похожая тема — файловый ввод-вывод, который выглядит синхронным, но на деле прячет асинхронную механику ядра — разобрана в статье про то, как ядро выбирает жертву при нехватке памяти: страницы mmap-отображений тоже участвуют в общем учёте памяти, и агрессивно замапленные большие файлы влияют на решения OOM killer так же, как обычный page cache.
Практика: учёт памяти, msync и madvise
Хотя mmap не копирует данные при самом вызове, замапленные и реально подгруженные страницы всё равно занимают физическую RAM, пока лежат в page cache. Это создаёт неочевидный эффект: ps и top показывают резидентную память (RSS) процесса, включающую все подгруженные страницы его mmap-отображений — даже те страницы файла, которые несколько разных процессов делят между собой через MAP_SHARED.
Если несколько процессов замапили один и тот же файл, RSS каждого из них будет включать эти общие страницы — и суммарный RSS всех процессов может выглядеть так, будто памяти потребляется в разы больше, чем на самом деле физически занято. Для честной оценки нужно смотреть на PSS (proportional set size, где общая память делится пропорционально между процессами) в /proc/<pid>/smaps_rollup, а не на голый RSS.
Под давлением памяти в системе ядро может вытеснить страницы mmap-файлов из RAM точно так же, как обычные страницы page cache — если это MAP_PRIVATE-страница с изменениями (dirty), она сначала уходит в swap; если это неизменённая file-backed страница, её можно просто выбросить и перечитать с диска при следующем обращении, потому что оригинал всё ещё цел на диске. Как именно ядро расставляет приоритеты при нехватке памяти между анонимными страницами и файловыми — тема статьи про то, как работает swap.
Если вы пишете в MAP_SHARED-область, изменения физически попадают в page cache сразу, но на диск — асинхронно, по тем же правилам writeback, что и обычная запись через write(). Чтобы гарантированно сбросить изменения на диск (например, перед тем как считать транзакцию завершённой), нужен явный вызов msync() с флагом MS_SYNC — без него в случае аварийного отключения питания можно потерять те же данные, что и при обычной буферизованной записи без fsync().
Ядро также даёт подсказки через madvise(), которыми можно скорректировать поведение по умолчанию:
MADV_SEQUENTIAL— сообщить ядру, что доступ будет преимущественно последовательным, чтобы оно агрессивнее делало упреждающее чтение (readahead) и быстрее вытесняло уже прочитанные страницы.MADV_RANDOM— обратное: доступ случайный, упреждающее чтение только мешает и тратит память впустую.MADV_WILLNEED— заранее попросить ядро подгрузить страницы в кеш, до того как они реально понадобятся, чтобы избежать page fault на критическом пути.MADV_DONTNEED— сообщить, что диапазон страниц больше не нужен, и их можно освободить немедленно.
При этом mmap — не универсальный ответ на любую задачу файлового ввода-вывода. Для файлов, которые читаются последовательно целиком один раз (типичный случай — потоковая обработка логов или бэкапов), накладные расходы на управление таблицей страниц и page fault на каждую новую страницу иногда обходятся дороже, чем простой последовательный read() в заранее выделенный буфер с явным упреждающим чтением. Для очень маленьких файлов, которые читаются редко, разница между read и mmap обычно вообще не заметна, а код с mmap сложнее в отладке. Выбор между ними — это вопрос паттерна доступа и профилирования конкретной нагрузки, а не универсального правила «mmap всегда быстрее».
Нужен сервер под эту задачу?
Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации UK, США, Франция и РФ. Оплата картой РФ и по СБП.
Арендовать серверНужны сами нейросети для контента?
Генерируйте изображения, видео и озвучку нейросетями на falapi.io — десятки моделей в одном окне. Оплата картой РФ и по СБП.
Частые вопросы
Чем mmap отличается от обычного чтения файла в буфер?
При обычном read() ядро копирует данные из page cache в буфер приложения — это одна лишняя копия по сравнению с mmap, где приложение обращается напрямую к страницам page cache через указатель, без промежуточного буфера.
Что будет, если файл, к которому обращается mmap, удалили или урезали другим процессом?
Если файл удалили — замапленные и уже загруженные в память страницы останутся доступны (файл физически не исчезает, пока на него есть открытые ссылки, в том числе через отображение), но обращение к ещё не подгруженным страницам за пределами нового размера файла (после truncate()) приведёт к SIGBUS.
Нужно ли вызывать munmap() вручную?
Да, отображение нужно снимать явным munmap() (или оно снимается автоматически при завершении процесса), иначе адресное пространство процесса продолжает занимать зарезервированный диапазон, даже если данные больше не используются.
Можно ли через mmap записывать данные, которых раньше не было в файле, то есть увеличивать его размер?
Нет напрямую — размер отображения фиксируется в момент вызова mmap() и соответствует размеру файла на тот момент. Чтобы увеличить файл, сначала используют ftruncate() (или write()) для расширения самого файла, а затем создают новое отображение нужного размера или перемапливают существующее через mremap().
Даёт ли mmap какие-то гарантии атомарности записи?
Нет. Запись через указатель в замапленную область — это набор обычных инструкций процессора, без каких-либо транзакционных гарантий на уровне ядра. Если нужна атомарность операции (например, замена содержимого файла целиком), стандартный паттерн — записать во временный файл и атомарно переименовать его поверх целевого, а не полагаться на порядок записи через mmap.
Обсудить статью, задать вопрос или начать новую тему
Есть вопрос по этой статье, идея для обсуждения или просто хотите поделиться опытом? Сообщество MAATRIX ждёт. Для общения, пожалуйста, зарегистрируйтесь в нашем личном кабинете.
Перейти в сообщество →