Memory mapping или отображение файлов в память это механизм, при котором операционная система связывает участок виртуального адресного пространства процесса с содержимым файла на диске, не копируя данные заранее. Программа получает обычный указатель и читает байты так, словно они уже лежат в оперативной памяти, а ядро незаметно подгружает нужные фрагменты в момент обращения. Благодаря этому утилита может открыть журнал объёмом пятьдесят гигабайт за долю секунды: время уходит только на создание отображения, а не на чтение данных. Далее рассматривается, как устроен этот механизм в Windows и Linux, почему он быстрее классического чтения, какие у него ограничения и где он применяется на практике.

Отображение файла в виртуальное адресное пространство

В Windows цепочка выглядит так. Сначала файл открывается функцией CreateFile, затем CreateFileMapping создаёт объект отображения, который описывает связь файла с памятью, и наконец MapViewOfFile проецирует этот объект на конкретный диапазон виртуальных адресов вызывающего процесса. В Linux и других системах семейства Unix вся работа выполняется одним вызовом mmap, которому передаются файловый дескриптор, смещение, длина и флаги доступа. В обоих случаях результат одинаков: процесс получает адрес, начиная с которого байты файла выглядят как обычный массив в памяти, хотя физически ни одного байта ещё не прочитано.

Ключевой момент состоит в том, что система резервирует именно адреса, а не память. Диапазон виртуальных адресов помечается как отображённый на файл, записи об этом попадают в таблицы страниц, но физические страницы не выделяются. Поэтому открытие огромного файла занимает миллисекунды независимо от его размера. Стоимость операции пропорциональна объёму служебных структур ядра, а не объёму данных.

Страницы подгружаются по требованию через page fault

Реальное чтение с диска происходит лениво. Когда код впервые обращается к байту внутри отображённого диапазона, процессор обнаруживает, что соответствующая страница отсутствует в физической памяти, и генерирует исключение page fault. Обработчик в ядре проверяет, что адрес принадлежит допустимому отображению, выделяет свободную физическую страницу, читает в неё нужный сектор диска или несколько соседних секторов с опережением и перезапускает прерванную инструкцию. Для программы всё это выглядит как обычное обращение к памяти, просто первое касание страницы занимает заметно больше времени, чем последующие.

Такая схема означает, что сектор диска считывается только тогда, когда к соответствующему адресу действительно обратились. Если программа просматривает лишь начало и конец пятидесятигигабайтного лога, то с диска будут подняты только те страницы, которые покрывают прочитанные участки. Размер страницы обычно составляет четыре килобайта, а упреждающее чтение поднимает небольшой блок рядом, поэтому фактический объём дискового ввода близок к объёму реально использованных данных, а не к размеру файла.

Общий кэш страниц и копирование при записи

Поднятые с диска страницы попадают в системный кэш страниц, который является общим для всех процессов. Если два процесса отображают один и тот же файл, оба работают с одними и теми же физическими страницами: данные не дублируются в памяти. Это особенно выгодно для разделяемых библиотек и исполняемых файлов, которые отображены сразу многими процессами.

Для записи в отображение применяется стратегия copy-on-write. Пока страница только читается, она остаётся общей. Как только процесс пытается её изменить в приватном отображении, ядро создаёт отдельную копию страницы для этого процесса, и дальнейшие изменения видны только ему. При общем отображении изменения наоборот попадают в кэш и становятся видны другим процессам, а затем сбрасываются на диск. В обоих случаях ядро избегает лишних копирований до тех пор, пока они действительно не понадобились.

Почему отображение быстрее классического чтения

При обычном чтении через ReadFile или read данные проходят лишний путь: ядро читает сектор в собственный буфер кэша, а затем копирует байты в буфер пользовательского процесса. Это копирование стоит процессорного времени и пропускной способности памяти, а также требует системного вызова на каждую порцию. При отображении данные остаются на месте: страница кэша напрямую присоединяется к адресному пространству процесса, и процессор читает её как обычную память. Копирования в пользовательские буферы нет вообще, копию делает только само ядро при первичном подъёме страницы с диска.

Запись тоже становится ленивой. Изменённые страницы помечаются как грязные, но не пишутся на диск немедленно. Фоновые потоки ядра периодически сбрасывают грязные страницы обратно в файл асинхронно, группируя записи в крупные эффективные операции. Приложение может ускорить процесс явным вызовом FlushViewOfFile или msync, но в обычном режиме запись завершается мгновенно, потому что ограничивается изменением байтов в памяти.

  1. Нет копирования данных из ядра в пользовательский буфер.
  2. Меньше системных вызовов на единицу прочитанных данных.
  3. Страницы кэша разделяются между процессами без дублирования.
  4. Упреждающее чтение и фоновая запись выполняются ядром оптимально.
  5. С диска поднимаются только реально затронутые страницы.

Ограничения и риски механизма

Отображение не бесплатно даже при нулевом чтении. Диапазон виртуальных адресов конечен, и на 32-разрядной системе процессу доступно обычно два-три гигабайта адресов. Отобразить пятидесятигигабайтный файл целиком там невозможно: приходится проецировать файл по окнам, отображая и убирая фрагменты по мере надобности, что усложняет код. На 64-разрядных системах адресного пространства достаточно, однако очень большое число отображений всё равно расходует структуры ядра и таблицы страниц.

Отдельной деталью является гранулярность отображения. Смещение внутри файла должно быть кратно гранулярности выделения памяти, которая в Windows составляет шестьдесят четыре килобайта, поэтому отобразить фрагмент с произвольного байта нельзя: приходится проецировать выровнённый блок и смещаться внутри него вручную. Ещё одна деталь касается производительности при случайном доступе на медленных носителях: каждый page fault на жёстком диске стоит миллисекунды ожидания, и паттерн чтения с постоянными промахами по страницам может оказаться медленнее последовательного буферизованного чтения, которое удобнее оптимизируется упреждением.

Вторая опасность связана с изменением размера файла под активным отображением. Если другой процесс усекает файл, обращение к адресам за новым концом вызывает ошибку шины или нарушение доступа, которое в Windows превращается в исключение и может уронить приложение. Расширение файла тоже требует аккуратности: отображение нужно пересоздать с новым размером, иначе добавленный хвост останется невидимым. Наконец, чтение через page fault уязвимо к аппаратным сбоям: ошибка ввода-вывода в этот момент приводит не к возврату кода ошибки из функции, а к исключению в произвольной точке кода, что трудно надёжно обработать.

Где отображение применяется на практике

Самая массовая область применения незаметна для пользователя: сама операционная система запускает исполняемые файлы именно через отображение. Загрузчик не читает exe в память целиком, а проецирует его секции на адресное пространство нового процесса, и страницы кода диспетчируются по мере выполнения. Поэтому программа с большим размером файла стартует быстро, а страницы, которые ни разу не исполнялись, никогда не покидают диск. То же относится к разделяемым библиотекам, чьи страницы кода одновременно присутствуют во многих процессах в единственном экземпляре.

Базы данных и поисковые движки используют отображение осознанно. Lucene и построенные на нём системы отображают индексные сегменты целиком, доверяя кэшу страниц решать, что держать в памяти: горячие части индекса оседают в кэше естественным образом, без самодельного управления буферами. Редакторы больших логов и hex-вьюеры устроены похожим образом: они отображают файл и рисуют только тот фрагмент, который попал в окно просмотра, поэтому мгновенно открывают файлы любой длины и прокручивают их без подгрузки гигабайтов.

Поведение потребления памяти при этом выглядит обманчиво. Когда программа читает отображённый файл, её рабочий набор растёт, потому что поднятые страницы учитываются на ней. Менеджер задач может показать, что утилита, просматривающая пятидесятигигабайтный лог, заняла десятки гигабайт. Паниковать при этом не следует: это кэш, а не утечка. Ядро мгновенно отбирает чистые страницы отображения при первой же нехватке памяти, потому что их содержимое всегда можно заново поднять с диска.

Сценарий сравнения потокового чтения и отображения на большом логе

Рассмотрим задачу найти строки с определённым маркером в логе объёмом пятьдесят гигабайт. Вариант с потоковым чтением открывает файл, последовательно читает его буферами по несколько мегабайт и сканирует каждый буфер. Каждая порция проходит копирование из кэша ядра в буфер процесса, и весь файл неизбежно проходит через этот конвейер. Если поиск запускается повторно с другим маркером, всё повторяется сначала, хотя часть страниц может остаться в кэше.

Вариант с отображением проецирует файл в адресное пространство и сканирует память напрямую, алгоритм поиска подстроки работает с указателями без единого системного вызова чтения. Первый проход всё равно поднимает все страницы через page fault, поэтому разница на холодном чтении умеренная. Но второй и третий запуски уже идут по страницам кэша со скоростью памяти, и здесь отображение выигрывает в разы, потому что отсутствует и копирование, и системные вызовы, и лишние аллокации буферов. Выигрыш становится решающим, когда нужен произвольный доступ: потоковое чтение вынуждено перематывать файл и перечитывать участки, тогда как отображение позволяет прыгать по адресам свободно, и ядро поднимает только затронутые страницы.

Итог практического применения отображений файлов

Отображение файлов превращает диск в естественное продолжение виртуальной памяти: программа работает с байтами по указателю, а ядро само решает, когда читать сектора, какие страницы держать в кэше и когда сбрасывать изменения. Обобщая сравнение, отображение выигрывает там, где данные читаются повторно, затрагивается лишь часть файла или нужен произвольный доступ, а потоковое чтение остаётся предпочтительным для однократного последовательного прохода, записи журналов без чтения и обработки каналов неизвестной длины, где адресное пространство проецировать просто не на что.

Механизм экономит память на разделяемых страницах, убирает лишние копирования и системные вызовы, мгновенно открывает файлы любого размера и лежит в основе загрузки программ, поисковых индексов и просмотрщиков огромных журналов. Расплата состоит в расходе виртуальных адресов, трудно обрабатываемых ошибках ввода-вывода и опасности изменения файла под активным отображением. Понимание этих свойств позволяет осознанно выбирать между потоковым чтением и отображением и правильно трактовать рост рабочего набора процесса как безобидный кэш страниц, а не признак утечки памяти.

Где mapping играет против чтения и где проигрывает. Отображение побеждает, когда к файлу нужен произвольный доступ и когда документы делятся между процессами: страницы файла живут в системном кеше по одному разу, и две программы, открывшие один образ, используют одну и ту же физическую память. Стоит избегать mapping для последовательной выгрузки в архив или малоёмкой перекодировки: там stream-чтение лучше рассказывает планировщику ввода-вывода о порядке, а отображение порождает дополнительные срабатывания страничных замечаний на горячих путях. Драйверы антивируса и игровые защиты тоже чувствительны к mapped boot: изменённая mapped-память должна быть скопирована на запись, поэтому copy-on-write горожане для редактируемых данных предпочтительнее прямой правки общей карты.

Отладка создания больших отображений. Разработчикам стоит помнить о ловушке 32-битной адресации: на Win32 объекты map свыше нескольких сотен мегабайт начинают конкурировать с кучей за адресное пространство, что порождает капризные отказы MapViewOfFile при видимой свободной памяти. Симптом редок, диагностика длинна, лечение коротко: перевести приложение на 64 бита либо окно с submapping по частям с ручным скольжением.