使用 Qt 多线程避免事件循环阻塞

2015-11-17 孙耀珠 调库

这学期选了一门物理实验的小课题「宇宙射线μ子探测」,于是需要给实验用到的程序写个 GUI。因为目标平台是 Windows,何宇就直接去写 WPF 了;而我本身不是 Windows 用户,当然倾向于寻找一个跨平台的解决方案,目前主流的 GUI 框架中 Qt 大概是最优雅的选择(知乎上也有对此的讨论)。

除了原生的 C++,Qt 也支持其他许多语言的绑定,譬如 PyQt、QtRuby 等等;Qt 近年新推的 Qt Quick 也改用了可以内嵌 JS 的新语言 QML。不过因为我恰好在上 C++ 的面向对象程序设计课,正想借此机会实践一下,所以我还是选择了原生的 C++。

因为实验模拟是个计算密集型的任务,整个计算函数要跑很长时间,如果直接调用它,必然会阻塞事件循环。这样一来,GUI 所有的绘制和交互都被阻塞在事件队列中,整个程序就失去响应了。

对于这样的阻塞一般有两种解决办法:一是在计算任务中不停地调用静态成员函数 QCoreApplication::processEvents() 来手动运行事件循环,它会在处理完队列中所有事件后返回。不过这样做毕竟没有从根本上解决问题,另外如果两次函数调用之间间隔的时间不够短,用户仍能明显感觉到程序卡顿。

第二种解决办法就是为任务新开一个线程,这样就能在不干扰 GUI 线程的情况下完成计算了。Qt 提供了三种控制线程的方式:QThread、QRunnable / QThreadPool、QtConcurrent,其中最通用、也是最常见的是 QThread。

QRunnable 是一个非常轻量的抽象类,它的主体是纯虚函数 QRunnable::run(),我们需要继承它并实现这个函数。使用时需要将其子类的实例放进 QThreadPool 的执行队列,线程池默认会在运行后自动删除这个实例。每个应用都有一个全局的线程池,我们可以直接使用它,这就不需要手动创建和管理线程池了。不过因为 QRunnable 不是 QObject 的子类,它没有内建与外界通信的手段,所以真正在使用时没那么实用。

class Task : public QRunnable
{
public:
    void run()
    {
        /* Implementation */
    }
};

/* ... */
QThreadPool::globalInstance()->start(new Task);

QtConcurrent 不是一个类,而是一个命名空间,内含一系列高度封装的多线程 API,基于前面提到的 QThreadPool。它可以并行地处理 MapReduce / FilterReduce,并能根据处理器实际的核心数调整所用的线程数,在执行过程中可以通过 QFuture 来获取结果、查询运行状态、暂停或取消任务。另外 QtConcurrent::run(Function function, ...) 可以启动一个新线程来运行所给的函数(可后接参数表),不过这时返回的 QFuture 不支持暂停和中止。使用这些 API 能完成大多数的多线程任务。

QThread 是 Qt 多线程调度中最核心的底层类,也是最常见的多线程实现方法。跟前面两者相比,QThread 的优势在于能够开启线程内的事件循环,为线程中所有 QObject 分发事件,以及能够设置自身的线程优先级。在 Qt 4.4 之前,QThread 跟 QRunnable 一样是一个抽象类,需要在子类中实现 QThread::run(),再将其实例化并调用成员函数 QThread::start() 即可运行。

class WorkerThread : public QThread
{
protected:
    void run()
    {
        /* Implementation */
    }
};

/* ... */
WorkerThread *workerThread = new WorkerThread;
workerThread->start();

但现在的 Qt 版本中 QThread::run() 不再是纯虚函数,其默认实现是调用 QThread::exec() 开启一个事件循环。因此,继承 QThread 实现多线程已不再是推荐的做法,更加优雅的做法是将计算任务和线程管理分离,即由专门的对象处理计算任务,再由线程管理器用 QObject::moveToThread() 为其分配合适的线程。

class Worker : public QObject
{
    Q_OBJECT

public slots:
    void doWork(const QString &parameter)
    {
        QString result;
        /* Blocking calculations */
        emit resultReady(result);
    }

signals:
    void resultReady(const QString &result);
};
class Controller : public QObject
{
    Q_OBJECT
    QThread workerThread;

public:
    Controller()
    {
        Worker *worker = new worker;
        worker->moveToThread(&workerThread);
        connect(this, &QThread::finished, worker, &QObject::deleteLater);
        connect(this, &Controller::operate, worker, &Worker::doWork);
        connect(worker, &Worker::resultReady, this, &Worker::handleResult);
        workerThread.start();
    }
    ~Controller()
    {
        workerThread.quit();
        workerThread.wait();
    }

public slots:
    void handleResult(const QString &result);

signals:
    void operate(const QString &parameter);
};

在新线程中执行计算任务时,我们会发现这时不能再访问 UI 了,这是因为 QWidget 及其子类都不是可重入的(reentrant),只能通过主线程访问。这样的设计虽然对开发者来说有些麻烦,但避免了可能导致的死锁或是复杂的 UI 同步。总之若要更新 UI 或是做其他交互,我们需要进行跨线程的对象间通信。这一任务可以通过静态函数 QMetaObject::invokeMethod() 来完成:

QMetaObject::invokeMethod(object, "factorial",
                          Qt::QueuedConnection,
                          Q_RETURN_ARG(QString, retVal),
                          Q_ARG(int, 48));

其中 Qt::QueuedConnection 意味着向对象所属进程发送事件,进入其事件循环以待执行。而 Qt 惯用的信号槽机制正基于此支持了跨线程通信,QObject::connect() 的最后一个参数可以指定连接类型,默认值 Qt::AutoConnection 表示如果目标线程就是当前进程则用 Qt::DirectConnection,否则采用 Qt::QueuedConnection 连接。

因为这一机制依赖 Qt 元对象编译器(moc)提供的内省(introspection),所以只有信号、槽和用 Q_INVOKABLE 宏标记的函数才能从别的线程调用。另外值得注意的是,如果所调函数的参数类型不是内建数据类型、不属于 QVariant,会抛出错误「QObject::connect: Cannot queue arguments of type ‘MyClass’」,即该类型的参数无法进入信号队列。要解决这个问题,我们可以在类的声明之后加上宏 Q_DECLARE_METATYPE(MyClass)、并调用 qRegisterMetaType<MyClass>("MyClass") 来将其注册为元类型,不过成为元类型的前提是该类提供了公有的构造函数、拷贝构造函数和析构函数。

参考文档:

Threads Events QObjects - Qt Wiki
QThread Class - Qt Documentation
QRunnable Class - Qt Documentation
QThreadPool Class - Qt Documentation
Qt Concurrent - Qt Documentation