如何在Qt中异步加载大文件中的数据

How can I asynchronously load data from large files in Qt?

本文关键字:文件 数据 加载 异步 Qt      更新时间:2023-10-16

我使用Qt 5.2.1来实现一个程序,该程序从文件中读取数据(可以是几个字节到几个GB),并以依赖于每个字节的方式可视化数据。我的例子是一个十六进制查看器。

一个对象进行读取,并在读取新的数据块时发出信号dataRead()。该信号携带指向QByteArray的指针,类似于:

filereader.cpp

void FileReader::startReading()
{
    /* Object state code here... */
        {
            QFile inFile(fileName);
            if (!inFile.open(QIODevice::ReadOnly))
            {
                changeState(STARTED, State(ERROR, QString()));
                return;
            }
            while(!inFile.atEnd())
            {
                QByteArray *qa = new QByteArray(inFile.read(DATA_SIZE));
                qDebug() << "emitting dataRead()";
                emit dataRead(qa);
            }
        }
    /* Emit EOF signal */
}

查看器的loadData插槽连接到此信号,这是显示数据的功能:

hexviewer.cpp

void HexViewer::loadData(QByteArray *data)
{
    QString hexString = data->toHex();
    for (int i = 0; i < hexString.length(); i+=2)
    {
        _ui->hexTextView->insertPlainText(hexString.at(i));
        _ui->hexTextView->insertPlainText(hexString.at(i+1));
        _ui->hexTextView->insertPlainText(" ");
    }
    delete data;
}

第一个问题是,如果按原样运行,GUI线程将变得完全没有响应。所有的dataRead()信号都将在重新绘制GUI之前发出。

(完整的代码可以运行,当你使用大于1kB的文件时,你会看到这种行为。)

根据对我论坛帖子Qt5中非阻塞本地文件IO的回应,以及对另一个堆栈溢出问题的回答如何在qt中执行异步文件IO?,答案是:使用线程。但是,这些答案都没有详细说明如何打乱数据本身,也没有说明如何避免常见的错误和陷阱。

如果数据很小(大约一百个字节),我会把它和信号一起发射出去。但是,如果文件大小为GB(edit,或者文件位于基于网络的文件系统上,例如NFS、Samba共享,我不希望UI仅仅因为读取文件块而锁定。

第二个问题是,在发射器中使用new,在接收器中使用delete的机制似乎有点天真:我有效地将整个堆用作跨线程队列。

问题1:Qt是否有更好/惯用的方法在限制内存消耗的同时跨线程移动数据?它是否有线程安全队列或其他可以简化整个过程的结构?

问题2:是否有自己实现线程等?我不太喜欢重新发明轮子,尤其是在内存管理和线程方面。是否有更高级别的构建已经可以做到这一点,就像网络传输一样?

首先,您的应用程序中根本没有任何多线程。您的FileReader类是QThread的子类,但这并不意味着所有FileReader方法都将在另一个线程中执行。事实上,所有操作都是在主(GUI)线程中执行的。

FileReader应该是QObject而不是QThread子类。然后创建一个基本的QThread对象,并使用QObject::moveToThread将工作人员(读取器)移动到该对象。你可以在这里阅读有关这项技术的内容。

请确保您已使用qRegisterMetaType注册了FileReader::State类型。这对于Qt信号槽连接跨不同线程工作是必要的。

一个例子:

HexViewer::HexViewer(QWidget *parent) :
    QMainWindow(parent),
    _ui(new Ui::HexViewer),
    _fileReader(new FileReader())
{
    qRegisterMetaType<FileReader::State>("FileReader::State");
    QThread *readerThread = new QThread(this);
    readerThread->setObjectName("ReaderThread");
    connect(readerThread, SIGNAL(finished()),
            _fileReader, SLOT(deleteLater()));
    _fileReader->moveToThread(readerThread);
    readerThread->start();
    _ui->setupUi(this);
    ...
}
void HexViewer::on_quitButton_clicked()
{
    _fileReader->thread()->quit();
    _fileReader->thread()->wait();
    qApp->quit();
}

此外,这里没有必要在堆上分配数据:

while(!inFile.atEnd())
{
    QByteArray *qa = new QByteArray(inFile.read(DATA_SIZE));
    qDebug() << "emitting dataRead()";
    emit dataRead(qa);
}

QByteArray使用隐式共享。这意味着在只读模式下跨函数传递QByteArray对象时,其内容不会被一次又一次地复制。

将上面的代码更改为这个,忘记手动内存管理:

while(!inFile.atEnd())
{
    QByteArray qa = inFile.read(DATA_SIZE);
    qDebug() << "emitting dataRead()";
    emit dataRead(qa);
}

但无论如何,主要问题不在于多线程。问题是QTextEdit::insertPlainText操作并不便宜,尤其是当您有大量数据时。FileReader非常快速地读取文件数据,然后用要显示的新数据部分淹没小部件。

必须注意的是,您对HexViewer::loadData的实现非常无效。您一个字符接一个字符地插入文本数据,这使得QTextEdit不断地重新绘制其内容并冻结GUI。

您应该首先准备生成的十六进制字符串(注意,数据参数不再是指针):

void HexViewer::loadData(QByteArray data)
{
    QString tmp = data.toHex();
    QString hexString;
    hexString.reserve(tmp.size() * 1.5);
    const int hexLen = 2;
    for (int i = 0; i < tmp.size(); i += hexLen)
    {
        hexString.append(tmp.mid(i, hexLen) + " ");
    }
    _ui->hexTextView->insertPlainText(hexString);
}

无论如何,应用程序的瓶颈不是文件读取,而是QTextEdit更新。按块加载数据,然后使用QTextEdit::insertPlainText将其附加到小部件不会加快任何速度。对于小于1Mb的文件,一次读取整个文件,然后一步将结果文本设置到小部件会更快。

我想,使用默认的Qt小部件,您无法轻松显示大于几兆字节的巨大文本。此任务需要一些非琐碎的方法,这些方法通常与多线程或异步数据加载无关。这一切都是为了创建一些棘手的小部件,它不会试图同时显示其庞大的内容。

这似乎是您想要一个具有信号量的消费者-生产者的情况。有一个非常具体的例子可以引导你正确地实现它。你需要一个额外的线程来使它与主线程分开工作。

设置应为:

  • 线程A以生产者身份运行文件读取器
  • GUI线程运行Hexviewer小部件,该小部件消耗特定事件的数据在发出QSemaphore::acquire()之前,应使用QSemahore::available()`进行检查,以避免阻塞GUI
  • Filereader和Hexviewer可以访问第三类,例如DataClass,其中数据在读取时放置,并从消费者处检索。这也应该定义信号量
  • 不需要发出带有数据的信号或通知

这几乎涵盖了将从filereader读取的数据移动到小部件,但并不涵盖如何实际绘制这些数据。为了实现这一点,您可以通过覆盖Hexviewer的绘制事件并读取队列中的内容来使用paintevent中的数据。更详细的方法是编写一个事件过滤器。

除此之外,您可能希望读取最大字节数,然后显式地用信号通知Hexviewer使用数据。

请注意,这个解决方案是完全异步的、线程安全的和有序的,因为没有任何数据发送到Hexviewer,但Hexviewers只在需要在屏幕上显示时使用这些数据。

  1. 如果您计划编辑10GB文件,请忘记QTextEdit。在您读取文件的1/10之前,这个ui->hexTextView->insertPlainText只会吃掉整个内存。IMO您应该使用QTableView来显示和编辑数据。为此,您应该继承QAbstractTableModel。在一行中,您应该显示16个字节。前16列为十六进制,下一列为ASCII。这应该不会太复杂。只是可怕地阅读QAbstractTableModel的文档。缓存数据在这里将是最重要的。如果我有时间,我会给出代码示例。

  2. 忘记使用多线程。使用这样的东西是不好的,很可能会产生很多与同步相关的问题。

好的,我花了一些时间,这里是正在工作的代码(我已经测试过它工作得很顺利):

#include <QObject>
#include <QFile>
#include <QQueue>
class LargeFileCache : public QObject
{
    Q_OBJECT
public:
    explicit LargeFileCache(QObject *parent = 0);
    char geByte(qint64 pos);
    qint64 FileSize() const;
signals:
public slots:
    void SetFileName(const QString& filename);
private:
    static const int kPageSize;
    struct Page {
        qint64 offset;
        QByteArray data;
    };
private:
    int maxPageCount;
    qint64 fileSize;
    QFile file;
    QQueue<Page> pages;
};
#include <QAbstractTableModel>
class LargeFileCache;
class LageFileDataModel : public QAbstractTableModel
{
    Q_OBJECT
public:
    explicit LageFileDataModel(QObject *parent);
    // QAbstractTableModel
    int rowCount(const QModelIndex &parent) const;
    int columnCount(const QModelIndex &parent) const;
    QVariant data(const QModelIndex &index, int role) const;
signals:
public slots:
    void setFileName(const QString &fileName);
private:
    LargeFileCache *cachedData;
};
#include "lagefiledatamodel.h"
#include "largefilecache.h"
static const int kBytesPerRow = 16;
LageFileDataModel::LageFileDataModel(QObject *parent)
    : QAbstractTableModel(parent)
{
    cachedData = new LargeFileCache(this);
}
int LageFileDataModel::rowCount(const QModelIndex &parent) const
{
    if (parent.isValid())
        return 0;
    return (cachedData->FileSize() + kBytesPerRow - 1)/kBytesPerRow;
}
int LageFileDataModel::columnCount(const QModelIndex &parent) const
{
    if (parent.isValid())
        return 0;
    return kBytesPerRow;
}
QVariant LageFileDataModel::data(const QModelIndex &index, int role) const
{
    if (index.parent().isValid())
        return QVariant();
    if (index.isValid()) {
        if (role == Qt::DisplayRole) {
            qint64 pos = index.row()*kBytesPerRow + index.column();
            if (pos>=cachedData->FileSize())
                return QString();
            return QString::number((unsigned char)cachedData->geByte(pos), 0x10);
        }
    }
    return QVariant();
}
void LageFileDataModel::setFileName(const QString &fileName)
{
    beginResetModel();
    cachedData->SetFileName(fileName);
    endResetModel();
}
#include "largefilecache.h"
const int LargeFileCache::kPageSize = 1024*4;
LargeFileCache::LargeFileCache(QObject *parent)
    : QObject(parent)
    , maxPageCount(1024)
{
}
char LargeFileCache::geByte(qint64 pos)
{
    // largefilecache
    if (pos>=fileSize)
        return 0;
    for (int i=0, n=pages.size(); i<n; ++i) {
        int k = pos - pages.at(i).offset;
        if (k>=0 && k< pages.at(i).data.size()) {
            pages.enqueue(pages.takeAt(i));
            return pages.back().data.at(k);
        }
    }
    Page newPage;
    newPage.offset = (pos/kPageSize)*kPageSize;
    file.seek(newPage.offset);
    newPage.data = file.read(kPageSize);
    pages.push_front(newPage);
    while (pages.count()>maxPageCount)
        pages.dequeue();
    return newPage.data.at(pos - newPage.offset);
}
qint64 LargeFileCache::FileSize() const
{
    return fileSize;
}
void LargeFileCache::SetFileName(const QString &filename)
{
    file.close();
    file.setFileName(filename);
    file.open(QFile::ReadOnly);
    fileSize = file.size();
}

它比我预期的要短,需要一些改进,但它应该是一个很好的基础。

对于十六进制查看器,我认为您根本没有走在正确的轨道上——除非您认为它很可能用于带有SCSI或RAID阵列的系统以提高速度。为什么一次要加载千兆字节的数据?如今,通过文件访问来填充文本框的速度相当快。诚然,例如Notepad++有一个出色的十六进制查看器插件,你必须先加载文件;但这是因为文件可能会被编辑,这就是NPP的工作方式。

我认为你最终可能会对一个文本框进行子类化,获取足够的数据来加载文本框,甚至挥霍,并在当前位置前后加载500k的数据。然后,假设您从零字节开始。为您的显示器加载足够的数据,也许还有一些额外的数据;但是将滚动条类型设置为始终可见。然后,我认为您可能会通过子类化QTextBox来截取滚动事件;以及编写您自己的scrollContentsBy()和changeEvent()和/或paint()事件。

更简单的是,你可以创建一个QTextBox,永远不带滚动条;旁边有一个QVerticalScrollbar。设置它的范围和起始值。然后,响应valueChanged()事件;并更改QTextBox的内容。这样,用户就不必等待随盘读取才能开始编辑,而且在资源(即内存)方面会容易得多,这样,如果很多应用程序都打开了,它们就不会被换到磁盘上)。把这些东西分类听起来很难,但很多时候,它似乎比实际情况更难。通常已经有很好的例子表明有人在做这样的事情。

相比之下,如果有多个线程在读取一个文件,则可能会有一个从开始读取,另一个从中间读取,以及另一个向结尾读取。单个读取头将四处跳跃,试图满足所有请求,因此操作效率较低。如果是SDD驱动器,非线性读取不会对您造成伤害,但也不会对您有所帮助。如果你更喜欢在加载时间上进行权衡,这样用户就可以随意、快速地滚动(毕竟,加载一个装满数据的文本框并不需要很长时间),那么你可能会让一个线程在后台读取它,然后你可以让主线程继续处理事件循环。更简单的是,当它一次打开整个文件时,只需一次读取n兆字节的块,然后执行qApp->processEvents();,让GUI在每次读取块后对在此期间可能发生的任何GUI事件做出响应。

如果您确实认为它很可能会在SCSI或RAID阵列上使用,那么进行多步读取可能是有意义的。SCSI驱动器可以有多个读出头;并且为了速度目的,一些RAID阵列被设置为将它们的数据分布在多个磁盘上。请注意,如果RAID阵列设置为为了数据安全起见保留多个相同的数据副本,则最好使用单个线程进行读取。当我去实现多线程时,我发现这里提出的轻量级模型最有帮助:QThread:你没有做错。我必须对结果结构执行Q_DECLARE_METATYPE,为其定义构造函数、析构函数和移动运算符(我使用了memmove),并对结构和向量执行qRegisterMetaType()以保存结果,使其正确返回结果。为了返回结果,你付出了它阻塞向量的代价;但实际开销似乎根本不算多。在这种情况下,共享内存可能也值得追求,但也许每个线程都有自己的,所以你不需要锁定其他线程结果的读取来写入它