Команда Go for Devs подготовила перевод статьи о том, как memory maps (mmap) обеспечивают молниеносный доступ к файлам в Go. Автор показывает, что замена обычного чтения и записи на работу с памятью может ускорить программу в 25 раз — и объясняет, почему это почти магия, но с нюансами.


Одно из самых медленных действий, которое можно совершить в приложении, — это системный вызов. Они медленные, потому что требуют перехода в ядро, а это дорогостоящая операция. Что делать, если нужно выполнять много операций ввода-вывода с диском, но при этом важна производительность? Один из вариантов — использовать memory maps.

Memory maps — это современный механизм Unix, позволяющий «включить» файл в виртуальную память. В контексте Unix «современный» означает появившийся где-то в 1980-х или позже. У вас есть файл с данными, вы вызываете mmap, и получаете указатель на участок памяти, где эти данные находятся. Теперь, вместо того чтобы делать seek и read, вы просто читаете по этому указателю, регулируя смещение, чтобы попасть в нужное место.

Производительность

Чтобы показать, какой прирост производительности можно получить с помощью memory maps, я написал небольшую библиотеку на Go, которая позволяет читать файл через memory map или через ReaderAt.
ReaderAt использует системный вызов pread(), совмещающий seek и read, а mmap просто читает напрямую из памяти.

Случайное чтение (ReaderAt): 416.4 ns/op
Случайное чтение (mmap):      3.3 ns/op
---
Итерация (ReaderAt):          333.3 ns/op
Итерация (mmap):              1.3 ns/op

Почти как магия. Когда мы запускали Varnish Cache в 2006 году, именно эта возможность делала его настолько быстрым при выдаче контента. Varnish Cache использовал memory maps, чтобы доставлять данные с невероятной скоростью.

Кроме того, поскольку можно работать с указателями на память, выделенную memory map, снижается нагрузка на память и уменьшается общая задержка.

Обратная сторона memory maps

Недостаток memory maps в том, что по сути в них нельзя писать. Причина кроется в устройстве виртуальной памяти.

Когда вы пытаетесь записать в область виртуальной памяти, которая не отображена на физическую, процессор вызывает page fault. Современный CPU отслеживает, какие страницы виртуальной памяти соответствуют каким физическим страницам.

Если вы пишете в страницу, которая не сопоставлена, процессору нужна помощь.

При page fault операционная система:

  1. Выделяет новую страницу памяти.

  2. Считывает содержимое файла по нужному смещению.

  3. Записывает это содержимое в новую страницу.
    Затем управление возвращается приложению, и оно перезаписывает эту страницу новыми данными.

Можно просто поаплодировать тому, насколько это неэффективно. Думаю, можно с уверенностью сказать: писать через memory map — плохая идея, если важна производительность, особенно если есть риск, что файл ещё не загружен в физическую память.

Вот несколько бенчмарков:

Запись через mmap, страницы не в памяти: 1870 ns/op
Запись через mmap, страницы в памяти:     79 ns/op
WriterAt:                                 303 ns/op

Как видно, наличие страниц в кэше критически влияет на производительность. WriterAt, использующий системный вызов pwrite, даёт куда более предсказуемые результаты.

Тем не менее, в начале Varnish Cache действительно писал через memory map. Как-то это срабатывало — в основном потому, что конкуренты тогда были гораздо хуже.

Позже у Varnish Cache появился malloc backend, а у Varnish Enterprise — несколько Massive Storage Engines.malloc backend решал проблему, просто выделяя память через системный вызов malloc, а Massive Storage Engine использует io_uring, который настолько новый, что поддержка его пока ещё ограничена.

Использование memory maps для решения реальных проблем производительности

Последние пару недель я работал над файловой системой с поддержкой HTTP. Это часть нашего решения AI Storage Acceleration, предназначенного для высокопроизводительных вычислительных сред.

В этой файловой системе нам нужно было передавать данные каталогов по HTTP. Каталог — это, по сути, список файлов, символических ссылок и подкаталогов. Наивный подход — просто сериализовать всё в JSON, но JSON печально известен своей медлительностью.

Наша главная цель — производительность. Мы сделали набор бенчмарков, сравнив различные базы данных. CDB оказалась самой быстрой в целом.
Тем не менее, даже она тратила примерно 1200 наносекунд на запрос к БД, полностью находящейся в кэше страниц. Это казалось подозрительно медленным. Ведь всё в памяти — зачем 1200ns на чтение? Это должно быть хотя бы в сто раз быстрее.

Я посмотрел реализацию CDB, которую использовал. Там применялся тот же подход через ReaderAt, так что большая часть времени, вероятно, уходила на ожидание операционной системы.

Через несколько часов я заменил seek/read на использование memory map.
Результат — ускорение в 25 раз. И снова — почти магия. В отличие от старого файлового стевидора в Varnish Cache, здесь это ускорение не имеет никаких побочных эффектов.

Русскоязычное Go сообщество

Друзья! Эту статью подготовила команда «Go for Devs» — сообщества, где мы делимся практическими кейсами, инструментами для разработчиков и свежими новостями из мира Go. Подписывайтесь, чтобы быть в курсе и ничего не упустить!

Комментарии (5)


  1. aamonster
    25.10.2025 12:59

    Действительно всё настолько плохо с производительностью "обычных" API или, поигравшись с ними (по сути – закэшировав данные и уменьшив количество операций ввода/вывода/, можно достичь той же скорости, что и с memory map (грубо говоря, проблема уровня "не надо читать каждый байт отдельно")?

    Всегда считал, что memory maps в основном упрощают жизнь, позволяя избавиться от кучи потенциально содержащего ошибки кода, а не кардинально улучшают производительность.


    1. BadNickname
      25.10.2025 12:59

      Системные вызовы это дорого, а block cache и mmap у нас уже есть.

      Можно переписать в свою программу жирный кусок ядра линукса, параллельно потеряв все плюсы системного управления памятью, но зачем?


      1. aamonster
        25.10.2025 12:59

        Вообще не призываю переписывать, просто мне странно видеть "использование mmaps ускорило наш код в 25 раз" вместо "использование mmaps позволило нам удалить кучу легаси-кода, теперь проект гораздо проще поддерживать".


        1. BadNickname
          25.10.2025 12:59

          Если ты не пишешь что-то что делает много мелких чтений с диска - у тебя в принципе нет необходимости это оптимизировать.

          Если что-то начало делать много мелких чтений с диска и оптимизации дошли до этого чего-то - кажется логичным взять mmap и ускорить код в 25 раз, а не писать легаси код для кешей и всего связанного, чтобы потом заменять его на mmap


          1. aamonster
            25.10.2025 12:59

            Именно! Причём обычно это понятно ещё на этапе проектирования.