Сервис развивается: тестируем формат, собираем идеи, улучшаем сервис. Есть идеи?

Написать
Войти
Дайджесты
Иллюстрация к материалу: Проблема major page faults в Go: почему системный вызов mmap может замедлить программу

Проблема major page faults в Go: почему системный вызов mmap может замедлить программу

Инженер из VictoriaMetrics разобрал риски использования mmap для чтения файлов в высоконагруженных Go-сервисах. Обращение к холодным страницам памяти вызывает major page faults, блокирующие системные потоки незаметно для планировщика Go. Описано решение с гибридным ReaderAt и вызовом mincore.

Проблема major page faults в Go: почему системный вызов mmap может замедлить программу

Разработка современных высокопроизводительных систем хранения данных и СУБД требует от инженеров глубокого понимания взаимодействия прикладного программного кода с ядром операционной системы и аппаратной частью сервера. Одной из классических проблем при проектировании движков баз данных является выбор оптимального механизма для чтения информации с диска.

Ведущий инженер команды VictoriaMetrics на примере специализированного движка для работы с логами VictoriaLogs детально разобрал практические аспекты выбора между классическим системным вызовом pread и механизмом отображения файлов в виртуальную память процесса — mmap. Как выяснилось на практике, стандартное использование mmap в приложениях на языке Go может приводить к скрытым блокировкам и неожиданной деградации производительности всей системы.

Системный вызов pread и цена переключения контекста

Традиционный способ чтения данных из файлов в Unix-подобных операционных системах базируется на системных вызовах семейства read и pread. Главное преимущество pread заключается в том, что он принимает смещение в файле в качестве аргумента, позволяя осуществлять конкурентное чтение из одного дескриптора файла несколькими потоками без необходимости ручной синхронизации файлового указателя.

Однако за надежность и простоту приходится платить производительностью. Каждый вызов pread — это полноценный системный вызов, который требует переключения контекста процессора из пользовательского пространства (user-space) в пространство ядра (kernel-space), а затем обратно. Кроме того, ядро должно физически скопировать данные из своего внутреннего буферного кэша (Page Cache) в буфер приложения, выделенный в пользовательской памяти.

При обработке аналитических запросов, требующих выполнения миллионов мелких случайных чтений на один поисковый запрос (например, при сканировании индексов или битовых карт), накладные расходы на постоянное пересечение границы ядра становятся критическими. В тестах VictoriaLogs было зафиксировано, что процессор тратит до одной секунды чистого времени на каждые миллион системных вызовов только ради переключения контекста и копирования байтов, что является недопустимым расточительством ресурсов.

Отображение файлов в память (mmap): достоинства и скрытая угроза

Для обхода ограничений pread разработчики часто обращаются к механизму mmap. Этот системный вызов проецирует содержимое файла непосредственно на виртуальное адресное пространство процесса. В результате приложению больше не нужно вызывать функции операционной системы для чтения — работа с файлом превращается в обычное обращение к указателю в оперативной памяти (разразыменование указателя). Копирование данных из пространства ядра в user-space исключается, а переключения контекста CPU сводятся к минимуму.

Однако у mmap есть фундаментальная особенность, которая в многопоточных средах вроде Go превращается в серьезную архитектурную проблему. Когда программа пытается прочитать байты по адресу, соответствующему файлу, ядро ОС должно убедиться, что нужная страница файла загружена в физическую оперативную память. Если страница уже находится в Page Cache, обращение происходит мгновенно.

Но если страница является «холодной» (еще не считывалась с диска или была вытеснена из оперативной памяти из-за нехватки ОЗУ), возникает аппаратное прерывание, известное как major page fault (основной сбой страницы). В этот момент операционная система приостанавливает выполнение потока, инициировавшего чтение, и переводит его в состояние ожидания (D-state) до тех пор, пока контроллер диска не прочитает данные в оперативную память.

Механизм major page faults и планировщик Go

Блокировка потока на время чтения с диска — стандартный системный процесс, но в среде выполнения Go (Go Runtime) он приводит к катастрофическим последствиям для общей производительности приложения.

Планировщик Go работает по модели GMP, где логические задачи (горутины) распределяются по реальным потокам операционной системы (OS threads). Планировщик отлично справляется с ситуациями, когда горутина блокируется на сетевом вводе-выводе, операциях с каналами или явных системных вызовах. В этих случаях среда выполнения Go перехватывает блокировку, переносит другие горутины на свободные системные потоки, а заблокированный поток освобождает.

Однако сбой страницы памяти (major page fault) происходит на уровне процессора и ядра ОС совершенно прозрачно для среды выполнения Go. С точки зрения планировщика Go, поток операционной системы просто выполняет обычную инструкцию чтения из памяти. Планировщик не знает, что поток аппаратно заблокирован ядром на десятки миллисекунд в ожидании дисковой активности.

Если в системе происходит всплеск случайных чтений «холодных» данных через mmap, множество системных потоков Go одновременно зависают в состоянии ожидания диска. Поскольку планировщик не видит этой блокировки, он не может вовремя создать новые системные потоки взамен заблокированных. Как результат, выполнение всех горутин в приложении может полностью застопориться, вызывая резкие скачки задержек (latency spikes) даже на тех операциях, которые вообще не работают с диском.

Решение VictoriaMetrics: гибридный ReaderAt и системный вызов mincore

Чтобы объединить высокую скорость работы mmap и безопасность планировщика Go, инженеры VictoriaMetrics разработали гибридное решение для чтения файлов. Они реализовали специальную абстракцию ReaderAt, которая перед каждым обращением к данным динамически проверяет, находятся ли нужные страницы файла в оперативной памяти.

Для проверки резидентности страниц используется системный вызов mincore. Он позволяет получить битовую маску, показывающую, какие страницы виртуального адресного пространства процесса в данный момент загружены в Page Cache операционной системы.

Алгоритм работы гибридного ReaderAt выглядит следующим образом:

  1. При попытке чтения фрагмента файла программа выполняет вызов mincore для целевого диапазона адресов.
  2. Если mincore подтверждает, что все необходимые страницы находятся в оперативной памяти, приложение идет по «быстрому пути» (fast path) — мгновенно считывает данные через отображение mmap без системных вызовов чтения и переключения контекста.
  3. Если же хотя бы одна страница отсутствует в ОЗУ, программа выбирает «безопасный путь» (slow path) — выполняет стандартный блокирующий системный вызов pread. В этом случае планировщик Go видит системный вызов, корректно обрабатывает блокировку потока и продолжает выполнять другие горутины на альтернативных потоках ОС, пока диск занят чтением.

Сам вызов mincore тоже является системным вызовом и имеет свою накладную цену. Чтобы избежать снижения производительности из-за его постоянного вызова, инженеры применили кэширование: результаты проверок страниц кэшируются в специальной битовой карте приложения на короткий срок (до одной минуты). Это обеспечивает оптимальный баланс: программа с высокой точностью знает состояние страниц памяти, не перегружая ядро лишними проверками, и гарантирует стабильно низкий уровень задержек всей системы хранения логов при работе под высокой нагрузкой.