QT中的线程与事件循环理解(1)

1.需要使用多线程管理的例子

  一个进程可以有一个或更多线程同时运行。线程可以看做是“轻量级进程”,进程完全由操作系统管理,线程即可以由操作系统管理,也可以由应用程序管理。Qt 使用QThread 来管理线程。

QWidget *widget = new QWidget(this);
    QVBoxLayout *layout = new QVBoxLayout;
    widget->setLayout(layout);
    QLCDNumber *lcdNumber = new QLCDNumber(this);
    layout->addWidget(lcdNumber);
    QPushButton *button = new QPushButton(tr("Start"), this);
    layout->addWidget(button);
    setCentralWidget(widget);

    QTimer *timer = new QTimer(this);
    connect(timer, &QTimer::timeout, [=]() {
        static int sec = 0;
        lcdNumber->display(QString::number(sec++));
    });

    //WorkerThread *thread = new WorkerThread(this);
    connect(button, &QPushButton::clicked, [=]() {
        timer->start(1);
        for (int i = 0; i < 2000000000; i++);
        timer->stop();
    });

 我们的主界面有一个用于显示时间的 LCD 数字面板还有一个用于启动任务的按钮。程序的目的是用户点击按钮,开始一个非常耗时的运算,程序中我们以一个 2000000000 次的循环来替代这个非常耗时的工作。同时 LCD 开始显示逝去的毫秒数。毫秒数通过一个计时器QTimer进行更新。计算完成后,计时器停止。这是一个很简单的应用,也看不出有任何问题。但是当我们开始运行程序时,问题就来了:点击按钮之后,程序界面直接停止响应,直到循环结束才开始重新更新。

   对于执行需要耗时的操作,需要多线程来进行处理。这是因为 Qt 中所有界面都是在 UI 线程中(也被称为主线程,就是执行了QApplication::exec()的线程),在这个线程中执行耗时的操作(比如那个循环),就会阻塞 UI 线程,从而让界面停止响应。界面停止响应,用户体验自然不好,不过更严重的是,有些窗口管理程序会检测到你的程序已经失去响应,可能会建议用户强制停止程序,这样一来你的程序可能就此终止,任务再也无法完成。

2.最简单有效的方法是,通过在在UI主线程中某个功能进行非常耗时的处理时,如等待一个信号,等,一种间接的方法是,在循环等待的语句中添加QT的下列语句,这样就不会处理主UI卡死的问题,但是,这种解决方法适应面窄,只能用于处理等待中结果的主循环卡死现象。如下列语句:

connect(button, &QPushButton::clicked, [=]() {
        timer->start(1);
        for (int i = 0; i < 2000000000; i++){
            QCoreApplication::processEvents();
        };
        timer->stop();
    });

  这样主UI线程就不会卡死。但是指标不治本。还要通过线程的方法来控制。

3. 我们增加了一个WorkerThread类。WorkerThread继承自QThread类,重写了其run()函数。我们可以认为,run()函数就是新的线程需要执行的代码。在这里就是要执行这个循环,然后发出计算完成的信号。而在按钮点击的槽函数中,使用QThread::start()函数启动一个线程(注意,这里不是run()函数)。再次运行程序,你会发现现在界面已经不会被阻塞了。另外,我们将WorkerThread::deleteLater()函数与WorkerThread::finished()信号连接起来,当线程完成时,系统可以帮我们清除线程实例。这里的finished()信号是系统发出的

class WorkerThread : public QThread
{
    Q_OBJECT
public:
    WorkerThread(QObject *parent = 0)
        : QThread(parent)
    {
    }
protected:
    void run()
    {
        for (int i = 0; i < 1000000000; i++);
        emit done();
    }
signals:
    void done();
};


    QTimer *timer = new QTimer(this);
    connect(timer, &QTimer::timeout, [=]() {
        static int sec = 0;
        lcdNumber->display(QString::number(sec++));
    });

    WorkerThread *thread = new WorkerThread(this);
    connect(thread, &WorkerThread::done, timer, &QTimer::stop);
    connect(thread, &WorkerThread::finished, thread, &WorkerThread::deleteLater);
    connect(button, &QPushButton::clicked, [=]() {
        timer->start(1);
        thread->start();
    });

2. 线程理解

  • 可重入的(Reentrant):如果多个线程可以在同一时刻调用一个类的所有函数,并且保证每一次函数调用都引用一个唯一的数据,就称这个类是可重入的(Reentrant means that all the functions in the referenced class can be called simultaneously by multiple threads, provided that each invocation of the functions reference unique data.)。大多数 C++ 类都是可重入的。类似的,一个函数被称为可重入的,如果该函数允许多个线程在同一时刻调用,而每一次的调用都只能使用其独有的数据。全局变量就不是函数独有的数据,而是共享的。换句话说,这意味着类或者函数的使用者必须使用某种额外的机制(比如锁)来控制对对象的实例或共享数据的序列化访问
  • 线程安全(Thread-safe):如果多个线程可以在同一时刻调用一个类的所有函数,即使每一次函数调用都引用一个共享的数据,就说这个类是线程安全的(Threadsafe means that all the functions in the referenced class can be called simultaneously by multiple threads even when each invocation references shared data.)。如果多个线程可以在同一时刻访问函数的共享数据,就称这个函数是线程安全的。

   进一步说,对于一个类,如果不同的实例可以被不同线程同时使用而不受影响,就说这个类是可重入的;如果这个类的所有成员函数都可以被不同线程同时调用而不受影响,即使这些调用针对同一个对象,那么我们就说这个类是线程安全的。由此可以看出,线程安全的语义要强于可重入。

  Qt 是事件驱动的。在 Qt 中,事件由一个普通对象表示(QEvent或其子类)。这是事件与信号的一个很大区别:事件总是由某一种类型的对象表示,针对某一个特殊的对象,而信号则没有这种目标对象。所有QObject的子类都可以通过覆盖QObject::event()函数来控制事件的对象。

 3. 事件与事件队列,事件分发器,事件循环

  事件可以由程序生成,也可以在程序外部生成。

  • QKeyEventQMouseEvent对象表示键盘或鼠标的交互,通常由系统的窗口管理器产生;
  • QTimerEvent事件在定时器超时时发送给一个QObject,定时器事件通常由操作系统发出;
  • QChildEvent在增加或删除子对象时发送给一个QObject,这是由 Qt 应用程序自己发出的。

   需要注意的是,与信号不同,事件并不是一产生就被分发。

  事件产生之后被加入到一个队列中(这里的队列含义同数据结构中的概念,先进先出),该队列即被称为事件队列事件分发器遍历事件队列,如果发现事件队列中有事件,那么就把这个事件发送给它的目标对象。这个循环被称作事件循环。事件循环的伪代码描述大致如下所示: 

while (is_active)
{
    while (!event_queue_is_empty) {
        dispatch_next_event();
    }
    wait_for_more_events();
}

正如前面所说的,调用QCoreApplication::exec() 函数意味着进入了主循环。我们把事件循环理解为一个无限循环,直到QCoreApplication::exit()或者QCoreApplication::quit()被调用,事件循环才真正退出。

  伪代码里面的while会遍历整个事件队列,发送从队列中找到的事件;wait_for_more_events()函数则会阻塞事件循环,直到又有新的事件产生。我们仔细考虑这段代码,在wait_for_more_events()函数所得到的新的事件都应该是由程序外部产生的。因为所有内部事件都应该在事件队列中处理完毕了。因此,我们说事件循环在wait_for_more_events()函数进入休眠,并且可以被下面几种情况唤醒:

  • 窗口管理器的动作(键盘、鼠标按键按下、与窗口交互等);
  • 套接字动作(网络传来可读的数据,或者是套接字非阻塞写等);
  • 定时器;
  • 由其它线程发出的事件(我们会在后文详细解释这种情况)。

  至于为什么需要事件循环,我们可以简单列出一个清单:

  • 组件的绘制与交互QWidget::paintEvent()会在发出QPaintEvent事件时被调用。该事件可以通过内部QWidget::update()调用或者窗口管理器(例如显示一个隐藏的窗口)发出。所有交互事件(键盘、鼠标)也是类似的:这些事件都要求有一个事件循环才能发出。
  • 定时器:长话短说,它们会在select(2)或其他类似的调用超时时被发出,因此你需要允许 Qt 通过返回事件循环来实现这些调用。
  • 网络:所有低级网络类(QTcpSocketQUdpSocket以及QTcpServer等)都是异步的。当你调用read()函数时,它们仅仅返回已可用的数据;当你调用write()函数时,它们仅仅将写入列入计划列表稍后执行。只有返回事件循环的时候,真正的读写才会执行。注意,这些类也有同步函数(以waitFor开头的函数),但是它们并不推荐使用,就是因为它们会阻塞事件循环。高级的类,例如QNetworkAccessManager则根本不提供同步 API,因此必须要求事件循环。

4, 不要阻塞事件循环

  有了事件循环,你就会想怎样阻塞它。阻塞它的理由可能有很多,例如我就想让QNetworkAccessManager同步执行。在解释为什么永远不要阻塞事件循环之前,我们要了解究竟什么是“阻塞”。假设我们有一个按钮Button,这个按钮在点击时会发出一个信号。这个信号会与一个Worker对象连接,这个Worker对象会执行很耗时的操作。当点击了按钮之后,我们观察从上到下的函数调用堆栈:

main(int, char **)
QApplication::exec()
[…]
QWidget::event(QEvent *)
Button::mousePressEvent(QMouseEvent *)
Button::clicked()
[…]
Worker::doWork()

 我们在main()函数开始事件循环,也就是常见的QApplication::exec()函数。窗口管理器侦测到鼠标点击后,Qt 会发现并将其转换成QMouseEvent事件,发送给组件的event()函数。这一过程是通过QApplication::notify()函数实现的。注意我们的按钮并没有覆盖event()函数,因此其父类的实现将被执行,也就是QWidget::event()函数。这个函数发现这个事件是一个鼠标点击事件,于是调用了对应的事件处理函数,就是Button::mousePressEvent()函数。我们重写了这个函数,发出Button::clicked()信号,而正是这个信号会调用Worker::doWork()槽函数

  在worker努力工作的时候,事件循环在干什么?或许你已经猜到了答案:什么都没做!事件循环发出了鼠标按下的事件,然后等着事件处理函数返回。此时,它一直是阻塞的,直到Worker::doWork()函数结束。注意,我们使用了“阻塞”一词,也就是说,所谓阻塞事件循环,意思是没有事件被派发处理,因为事件分发被阻塞在某个地方运行,不能够继续执行其他的事件分发。

  在事件就此卡住时,组件也不会更新自身(因为QPaintEvent对象还在队列中),也不会有其它什么交互发生(还是同样的原因),定时器也不会超时并且网络交互会越来越慢直到停止。也就是说,前面我们大费周折分析的各种依赖事件循环的活动都会停止。这时候,需要窗口管理器会检测到你的应用程序不再处理任何事件,于是告诉用户你的程序失去响应。这就是为什么我们需要快速地处理事件,并且尽可能快地返回事件循环。

5, 解决阻塞事件循环的方法

  重点来了:我们不可能避免业务逻辑中的耗时操作,那么怎样做才能既可以执行那些耗时的操作,又不会阻塞事件循环呢?

一般会有三种解决方案:

第一,我们将任务移到另外的线程, 多线程处理,新建线程处理该耗时任务;

第二,我们手动强制运行事件循环。想要强制运行事件循环,我们需要在耗时的任务中一遍遍地调用QCoreApplication::processEvents()函数。QCoreApplication::processEvents()函数会发出事件队列中的所有事件,并且立即返回到调用者。仔细想一下,我们在这里所做的,就是模拟了一个事件循环。

第三:使用使用QEventLoop类重新进入新的事件循环,即进行局部事件循环处理。通过调用QEventLoop::exec()函数,我们重新进入新的事件循环,给QEventLoop::quit()槽函数发送信号则退出这个事件循环。

  局部事件循环如下:

QEventLoop eventLoop;
connect(netWorker, &NetWorker::finished,  &eventLoop, &QEventLoop::quit);
QNetworkReply *reply = netWorker->get(url);
replyMap.insert(reply, FetchWeatherInfo);
eventLoop.exec();

QNetworkReply没有提供阻塞式 API,并且要求有一个事件循环。我们通过一个局部的QEventLoop来达到这一目的:当网络响应完成时,这个局部的事件循环也会退出。即在局部,可以通过局部时间循环,异质处理NetWorker获取get完成内容,如果NetWorker结束,则自动关闭该局部的事件循环。

(1)防止局部事件循环的递归调用问题

  通过“其它的入口”进入事件循环要特别小心:因为它会导致递归调用!现在我们可以看看为什么会导致递归调用了。

  函数中调用了QCoreApplication::processEvents()函数时,用户再次点击按钮,槽函数Worker::doWork()又一次被调用:而在doWork中又进入了另一个局部事件循环。

main(int, char **)
QApplication::exec()
[…]
QWidget::event(QEvent *)
Button::mousePressEvent(QMouseEvent *)
Button::clicked()
[…]
Worker::doWork() // <strong>第一次调用</strong>
QCoreApplication::processEvents() // <strong>手动发出所有事件</strong>
[…]
QWidget::event(QEvent * ) // <strong>用户又点击了一下按钮…</strong>
Button::mousePressEvent(QMouseEvent *)
Button::clicked() // <strong>又发出了信号…</strong>
[…]
Worker::doWork() // <strong>递归进入了槽函数!</strong>

 这种情况也有解决的办法:即在强制执行时间循环是,可以选择性的接受事件类型,如

  我们可以在调用QCoreApplication::processEvents()函数时传入QEventLoop::ExcludeUserInputEvents参数,意思是不要再次派发用户输入事件(这些事件仍旧会保留在事件队列中)。

6. QT中的多线程

  Qt 对线程的支持可以追溯到2000年9月22日发布的 Qt 2.2。在这个版本中,Qt 引入了QThread。不过,当时对线程的支持并不是默认开启的。Qt 4.0 开始,线程成为所有平台的默认开启选项(这意味着如果不需要线程,你可以通过编译选项关闭它,不过这不是我们现在的重点)。现在版本的 Qt 引入了很多类来支持线程。

  (1)QThread是第一个类。它也是 Qt 线程类中最核心的底层类。由于 Qt 的跨平台特性,QThread要隐藏掉所有平台相关的代码。正如前面所说,要使用QThread开始一个线程,我们可以创建它的一个子类,然后覆盖其QThread::run()函数, 然后我们这样使用新建的类来开始一个新的线程:

class Thread : public QThread
{
protected:
    void run()
    {
        /* 线程的相关代码 */
    }
};

Thread *thread = new Thread;
thread->start(); // 使用 start() 开始新的线程

 注意,从 Qt 4.4 开始,QThread就已经不是抽象类了。QThread::run()不再是纯虚函数,而是有了一个默认的实现。这个默认实现其实是简单地调用了QThread::exec()函数,而这个函数,按照我们前面所说的,其实是开始了一个事件循环。而对于最新的QT版本,则更简单,继承Run函数后,只需要start()就可以启动线程的时间循环了。

  (2)QRunnable是我们要介绍的第二个类。这是一个轻量级的抽象类,用于开始一个另外线程的任务。这种任务是运行过后就丢弃的。由于这个类是抽象类,我们需要继承QRunnable,然后重写其纯虚函数QRunnable::run()

  要真正执行一个QRunnable对象,我们需要使用QThreadPool类。顾名思义,这个类用于管理一个线程池。通过调用QThreadPool::start(runnable)函数,我们将一个QRunnable对象放入QThreadPool的执行队列。一旦有线程可用,线程池将会选择一个QRunnable对象,然后在那个线程开始执行。所有 Qt 应用程序都有一个全局线程池,我们可以使用QThreadPool::globalInstance()获得这个全局线程池;与此同时,我们也可以自己创建私有的线程池,并进行手动管理。

  需要注意的是,QRunnable不是一个QObject,因此也就没有内建的与其它组件交互的机制。为了与其它组件进行交互,你必须自己编写低级线程原语,例如使用 mutex 守护来获取结果等。

  (3)QtConcurrent是我们要介绍的最后一个对象。这是一个高级 API,构建于QThreadPool之上,用于处理大多数通用的并行计算模式:map、reduce 以及 filter。它还提供了QtConcurrent::run()函数,用于在另外的线程运行一个函数。注意,QtConcurrent是一个命名空间而不是一个类,因此其中的所有函数都是命名空间内的全局函数。

不同于QThreadQRunnableQtConcurrent不要求我们使用低级同步原语:所有的QtConcurrent都返回一个QFuture对象。这个对象可以用来查询当前的运算状态(也就是任务的进度),可以用来暂停/回复/取消任务,当然也可以用来获得运算结果。注意,并不是所有的QFuture对象都支持暂停或取消的操作。比如,由QtConcurrent::run()返回的QFuture对象不能取消,但是由QtConcurrent::mappedReduced()返回的是可以的。QFutureWatcher类则用来监视QFuture的进度,我们可以用信号槽与QFutureWatcher进行交互(注意,QFuture也没有继承QObject)。

  三个多线程相关的类的对比:

猜你喜欢

转载自blog.csdn.net/weiyaonan/article/details/121876662