У нас есть сервис аналитики, который каждый час обрабатывает логи — суммарно около 12 ГБ текстовых файлов. Когда я joined проект, обработка занимала два часа на инстансе с 8 vCPU и 32 ГБ RAM. Через неделю экспериментов с mmap я сократил время до трёх минут. Рассказываю, как это работает и где подстерегают грабли.
Проблема: std::ifstream слишком медленный
Наш исходный код использовал std::ifstream с std::getline для построчного чтения. На первый взгляд — нормальный подход. Но когда файл весит 4 ГБ, каждая строка проходит через: системный вызов read() в буфер ifstream, копирование в std::string, аллокация памяти для строки, парсинг. Это тысячи лишних аллокаций и копирований в секунду.
// Исходный код — 2 часа на 12 ГБ
void process_logs(const std::string& filepath) {
std::ifstream file(filepath);
std::string line;
while (std::getline(file, line)) {
auto entry = parse_line(line); // аллокация std::string внутри
if (entry.level == "ERROR") {
errors_.push_back(entry);
}
}
}
Профайлер показывал, что 60% времени тратится на аллокации в std::string и 25% — на системные вызовы read(). Только 15% — реальная бизнес-логика.
Решение: memory-mapped I/O
Идея mmap проста: вы просите ОС отобразить файл в виртуальную память процесса. ОС загружает страницы файла по мере обращения — через page faults. Вам не нужно явно читать данные, вы просто работаете с указателем как с обычным массивом байтов. ОС сама управляет кэшированием, prefetch'ом и выгрузкой неиспользуемых страниц.
#include
#include
#include
#include
#include
#include
class MappedFile {
int fd_ = -1;
void* data_ = nullptr;
size_t size_ = 0;
public:
MappedFile(const std::string& path) {
fd_ = open(path.c_str(), O_RDONLY);
if (fd_ < 0) throw std::runtime_error("open failed: " + path);
struct stat st;
if (fstat(fd_, &st) < 0) {
close(fd_);
throw std::runtime_error("fstat failed");
}
size_ = st.st_size;
data_ = mmap(nullptr, size_, PROT_READ, MAP_PRIVATE, fd_, 0);
if (data_ == MAP_FAILED) {
close(fd_);
throw std::runtime_error("mmap failed");
}
// Советуем ОС использовать sequential access pattern
madvise(data_, size_, MADV_SEQUENTIAL);
}
~MappedFile() {
if (data_) munmap(data_, size_);
if (fd_ >= 0) close(fd_);
}
// Запрет копирования
MappedFile(const MappedFile&) = delete;
MappedFile& operator=(const MappedFile&) = delete;
std::string_view data() const {
return {static_cast(data_), size_};
}
size_t size() const { return size_; }
};
Построчная обработка через string_view
Ключевой момент — мы не создаём std::string для каждой строки. Вместо этого используем std::string_view, который просто указывает на диапазон в отображённой памяти. Нулевые аллокации для строк.
void process_logs_mmap(const std::string& filepath) {
MappedFile file(filepath);
auto content = file.data();
size_t pos = 0;
while (pos < content.size()) {
size_t end = content.find('\n', pos);
if (end == std::string_view::npos) end = content.size();
auto line = content.substr(pos, end - pos);
auto entry = parse_line_sv(line); // string_view, без аллокаций
if (entry.level == "ERROR") {
errors_.push_back(Entry{
std::string(entry.timestamp), // аллоцируем только то, что сохраняем
std::string(entry.message)
});
}
pos = end + 1;
}
}
Мы аллоцируем std::string только для строк, которые реально сохраняем в результат. Для 12 ГБ логов, где ERROR составляет ~2%, это означает, что мы аллоцируем лишь ~240 МБ вместо 12 ГБ.
Page faults и как ОС управляет mapped memory
Когда вы обращаетесь к странице отображённого файла, которой ещё нет в физической памяти, процессор генерирует page fault. Ядро перехватывает его, читает соответствующий блок файла с диска в page cache и возобновляет инструкцию. Это прозрачно для вашего кода.
Важно понимать: mmap не загружает весь файл в RAM. Если у вас 4 ГБ файл и 2 ГБ свободной памяти — это нормально. ОС будет держать в RAM только активно используемые страницы, а остальные — сбрасывать. Мы вызываем madvise(..., MADV_SEQUENTIAL), чтобы ядро заранее читало следующие страницы и агрессивно выгружало уже прочитанные — для нашего use case это идеально.
Файлы больше доступной RAM
Самый большой файл в нашем пайплайне — 7.3 ГБ при 8 ГБ RAM на инстансе. С std::ifstream это работало, но медленно. С mmap — тоже работает, потому что ОС сама управляет страницами. Единственная настройка: мы разбиваем обработку на чанки по 512 МБ и вызываем madvise(..., MADV_DONTNEED) после обработки каждого чанка, чтобы освободить физическую память.
Бенчмарк: результаты
Измерения на одном и том же инстансе (8 vCPU, 32 GB RAM, NVMe SSD), 12 ГБ логов:
std::ifstream + std::getline: ~118 минутstd::ifstream + ручной буфер 64 КБ: ~42 минутыmmap + string_view: ~3.2 минутыmmap + string_view + парсинг в 4 потока: ~1.1 минуты
Ускорение в 100x по сравнению с исходным кодом. Да, парсинг в 4 потока даёт ещё 3x, но это уже тема отдельной статьи.
Когда НЕ использовать mmap
Сетевые файловые системы (NFS, SMB). mmap над NFS работает катастрофически медленно из-за особенностей кэширования. Если файл на сетевом диске — используйте обычный read().
Файлы, которые могут изменяться во время чтения. mmap с MAP_PRIVATE создаёт копию при записи (copy-on-write), но если другой процесс пишет в файл, вы можете получить непоследовательные данные. Используйте msync или блокировку файла.
Маленькие файлы. Для файлов меньше 1 МБ накладные расходы на mmap/munmap и настройку page table могут превысить выигрыш. std::ifstream с буфером 4 КБ будет быстрее.
Продовые советы
Всегда оборачивайте mmap в RAII-класс, как в примере выше. Забыть munmap — это утечка виртуального адресного пространства, которая на Linux не так критична, как утечка памяти, но всё равно неприятна. Обрабатывайте SIGBUS — этот сигнал приходит, если файл был укорочен после mmap. И помните, что mmap — это Linux/POSIX API, для кроссплатформенного кода вам понадобится CreateFileMapping на Windows или библиотека вроде boost::iostreams::mapped_file.
Для нас mmap стал стандартным инструментом для обработки логов, дампов и любых больших текстовых файлов. Два часа превратились в три минуты, а стоимость инстанса аналитики упала в четыре раза. Иногда самое эффективное решение — не оптимизировать код, а изменить способ доступа к данным.
Пока нет комментариев
Будьте первым, кто оставит комментарий к этой статье