Загрузка
Все статьи

C++: memory-mapped I/O для обработки больших файлов — от 2 часов до 3 минут

Как мы ускорили обработку логов на 40x с помощью mmap. Практическое руководство с бенчмарками, подводными камнями и сравнением с std::ifstream.

У нас есть сервис аналитики, который каждый час обрабатывает логи — суммарно около 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 стал стандартным инструментом для обработки логов, дампов и любых больших текстовых файлов. Два часа превратились в три минуты, а стоимость инстанса аналитики упала в четыре раза. Иногда самое эффективное решение — не оптимизировать код, а изменить способ доступа к данным.

Поделиться статьёй

Комментарии

0
0 / 2000

Пока нет комментариев

Будьте первым, кто оставит комментарий к этой статье

Читайте также

CMake: модульная архитектура для большого C++ проекта — без ad-hoc include путей

Как мы реорганизовали CMake-структуру проекта на 15+ модулей, избавились от хаотичных include_directories и перешли на современный CMake с target-based зависимостями.

20 июня 2026 10 мин

Kotlin + C++: реальный кейс — видеопроцессинг на сервере через JNI

Как мы построили сервер видеотранскодинга на Kotlin REST API + C++ FFmpeg через JNI. Прокачка JNI_OnLoad, передача фреймов через DirectByteBuffer, обработка ошибок и бенчмарки.

19 июня 2026 13 мин

C++: потоки и потокобезопасность — мьютексы, atomics и deadlock-ы на практике

Разбираем многопоточность в C++ на реальных примерах из продакшена: мьютексы, lock_guard, unique_lock, std::atomic, deadlock-ы и как их избежать. Сравнение с корутинами Kotlin.

18 июня 2026 11 мин