Команда 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 операционная система:
Выделяет новую страницу памяти.
Считывает содержимое файла по нужному смещению.
Записывает это содержимое в новую страницу.
Затем управление возвращается приложению, и оно перезаписывает эту страницу новыми данными.
Можно просто поаплодировать тому, насколько это неэффективно. Думаю, можно с уверенностью сказать: писать через 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, здесь это ускорение не имеет никаких побочных эффектов.
Бенчмарки: https://github.com/perbu/mmaps-in-go
CDB64 файлы с memory maps: https://github.com/perbu/cdb
Русскоязычное Go сообщество

Друзья! Эту статью подготовила команда «Go for Devs» — сообщества, где мы делимся практическими кейсами, инструментами для разработчиков и свежими новостями из мира Go. Подписывайтесь, чтобы быть в курсе и ничего не упустить!
aamonster
Действительно всё настолько плохо с производительностью "обычных" API или, поигравшись с ними (по сути – закэшировав данные и уменьшив количество операций ввода/вывода/, можно достичь той же скорости, что и с memory map (грубо говоря, проблема уровня "не надо читать каждый байт отдельно")?
Всегда считал, что memory maps в основном упрощают жизнь, позволяя избавиться от кучи потенциально содержащего ошибки кода, а не кардинально улучшают производительность.
BadNickname
Системные вызовы это дорого, а block cache и mmap у нас уже есть.
Можно переписать в свою программу жирный кусок ядра линукса, параллельно потеряв все плюсы системного управления памятью, но зачем?
aamonster
Вообще не призываю переписывать, просто мне странно видеть "использование mmaps ускорило наш код в 25 раз" вместо "использование mmaps позволило нам удалить кучу легаси-кода, теперь проект гораздо проще поддерживать".
BadNickname
Если ты не пишешь что-то что делает много мелких чтений с диска - у тебя в принципе нет необходимости это оптимизировать.
Если что-то начало делать много мелких чтений с диска и оптимизации дошли до этого чего-то - кажется логичным взять mmap и ускорить код в 25 раз, а не писать легаси код для кешей и всего связанного, чтобы потом заменять его на mmap
aamonster
Именно! Причём обычно это понятно ещё на этапе проектирования.