Работа с файлами и директориями в Qt

Memory-mapped файлы в Qt: QFile::map для быстрого доступа к большим данным

Введение

Обычное чтение файла через read() подразумевает копирование данных из буфера ядра ОС в буфер приложения. Для очень больших файлов (базы данных, индексы, видео, научные датасеты) это копирование становится заметным источником накладных расходов. Memory-mapped I/O — техника, при которой содержимое файла отображается прямо в адресное пространство процесса, и работа с диском превращается в обычную работу с указателем в памяти. Qt предоставляет доступ к этой технике через QFile::map().

Концепция

QFile::map(qint64 offset, qint64 size, QFileDevice::MemoryMapFlags flags) отображает указанный диапазон байт файла в память и возвращает uchar* — прямой указатель на начало отображённого региона. Операционная система сама управляет подгрузкой страниц с диска по мере обращения к ним (lazy loading), что особенно выгодно при случайном доступе к небольшим фрагментам очень большого файла.

Для записи в отображённую область достаточно изменить байты по указателю — изменения попадают на диск через механизм отложенной записи ОС (или принудительно через flush, в зависимости от платформы). После использования регион нужно явно освободить через unmap().

Пример кода: Memory-mapped файлы в Qt

#include <QFile>
#include <QDebug>
#include <QElapsedTimer>

// Подсчёт количества вхождений байта в большом файле через mmap
qint64 countByteOccurrences(const QString &path, char target)
{
    QFile file(path);
    if (!file.open(QIODevice::ReadOnly)) {
        qWarning() << "Не удалось открыть файл:" << file.errorString();
        return -1;
    }

    qint64 fileSize = file.size();
    if (fileSize == 0)
        return 0;

    uchar *mapped = file.map(0, fileSize);
    if (!mapped) {
        qWarning() << "Не удалось отобразить файл в память:" << file.errorString();
        return -1;
    }

    qint64 count = 0;
    for (qint64 i = 0; i < fileSize; ++i) {
        if (mapped[i] == static_cast<uchar>(target))
            ++count;
    }

    file.unmap(mapped);
    file.close();
    return count;
}

// Запись в файл через memory mapping: модификация фрагмента
// большого файла без перезаписи всего файла целиком
bool patchFileRegion(const QString &path, qint64 offset, const QByteArray &newData)
{
    QFile file(path);
    if (!file.open(QIODevice::ReadWrite)) {
        qWarning() << "Не удалось открыть файл для записи:" << file.errorString();
        return false;
    }

    if (offset + newData.size() > file.size()) {
        qWarning() << "Диапазон записи выходит за пределы файла";
        file.close();
        return false;
    }

    uchar *mapped = file.map(offset, newData.size());
    if (!mapped) {
        qWarning() << "Ошибка отображения файла:" << file.errorString();
        file.close();
        return false;
    }

    memcpy(mapped, newData.constData(), newData.size());

    file.unmap(mapped);
    file.close();
    return true;
}

void benchmarkMappedVsRegular(const QString &path)
{
    QElapsedTimer timer;

    // Обычное чтение
    timer.start();
    {
        QFile file(path);
        file.open(QIODevice::ReadOnly);
        QByteArray data = file.read(file.size());
        volatile qint64 sum = 0;
        for (char c : data) sum += c;
    }
    qDebug() << "Обычное чтение:" << timer.elapsed() << "мс";

    // Чтение через mmap
    timer.restart();
    {
        QFile file(path);
        file.open(QIODevice::ReadOnly);
        uchar *mapped = file.map(0, file.size());
        volatile qint64 sum = 0;
        if (mapped) {
            for (qint64 i = 0; i < file.size(); ++i) sum += mapped[i];
            file.unmap(mapped);
        }
    }
    qDebug() << "Memory-mapped чтение:" << timer.elapsed() << "мс";
}

Пояснения к коду

Параметр offset в map() на многих платформах должен быть кратен размеру страницы памяти ОС (как правило, 4096 байт) — Qt сам обрабатывает выравнивание внутри реализации, но при ручной работе со смещениями стоит об этом помнить, особенно при отображении не с начала файла.

В примере с записью важно открывать файл в режиме QIODevice::ReadWrite, иначе отображение будет доступно только для чтения, и попытка записи по указателю приведёт к нарушению доступа к памяти (segmentation fault).

Подводные камни

  1. unmap() обязателен перед закрытием/удалением файла. Если не освободить отображённую область до закрытия QFile или удаления файла, поведение неопределено на некоторых платформах — может произойти утечка ресурсов или ошибка при последующих операциях с файлом.
  1. Изменения через mmap не сразу попадают на диск. Запись по указателю модифицирует страницы в памяти, а фактическая синхронизация с диском происходит по решению ОС (или явно, если она доступна через платформенные функции типа msync, которые Qt не предоставляет напрямую). Для критичных данных это означает, что сбой питания сразу после записи может привести к потере изменений, даже если вызов memcpy уже выполнен.
  1. Падение по segmentation fault вместо штатной ошибки. В отличие от read()/write(), которые при ошибке возвращают код ошибки, неверное обращение к памяти за пределами отображённого региона (например, выход за границу size при вычислении индекса) приводит к падению всего процесса, а не к управляемому исключению.
  1. Не подходит для файлов, которые могут изменяться другим процессом во время отображения. Если другой процесс одновременно truncate’ит или изменяет размер файла, поведение отображения становится неопределённым — для разделяемых файлов нужна дополнительная синхронизация (блокировки, версионирование).
  1. Ограничения на 32-битных системах. Адресное пространство процесса на 32-битной платформе ограничено примерно 2-3 ГБ, поэтому отображение файлов больше нескольких сотен мегабайт может попросту не получиться из-за нехватки свободного диапазона виртуальных адресов. На 64-битных системах это практически не проблема.
  1. Выигрыш заметен не всегда. Memory-mapped I/O особенно эффективен при повторном или случайном доступе к одним и тем же данным (страницы кэшируются ОС) и при больших файлах. Для одноразового последовательного чтения небольшого файла обычный read() может оказаться не медленнее, а разница останется в пределах погрешности измерения.
  1. Платформенные различия в правах доступа. На некоторых системах (особенно в контейнерах с ограниченными правами или на файловых системах, не поддерживающих mmap, например некоторые сетевые ФС) map() может завершаться ошибкой там, где обычный read()/write() работают нормально — всегда проверяйте возвращаемое значение на nullptr и предусматривайте fallback на обычное чтение.

Memory-mapped файлы — мощный инструмент для работы с большими данными, дающий выигрыш в первую очередь за счёт устранения лишнего копирования и использования механизма виртуальной памяти ОС. Но эта мощь требует аккуратности: ошибки здесь проявляются не как контролируемые исключения, а как падения процесса, поэтому каждое обращение к отображённой памяти должно быть тщательно проверено на границы допустимого диапазона.