首页 Qt 学习之路 2 Qt 学习之路 2(72):线程和事件循环

Qt 学习之路 2(72):线程和事件循环

35 12

前面一章我们简单介绍了如何使用QThread实现线程。现在我们开始详细介绍如何“正确”编写多线程程序。我们这里的大部分内容来自于Qt的一篇Wiki文档,有兴趣的童鞋可以去看原文。

在介绍在以前,我们要认识两个术语:

  • 可重入的(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()函数来控制事件的对象。

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

  • 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()函数进入休眠,并且可以被下面几种情况唤醒:

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

在类 UNIX 系统中,窗口管理器(比如 X11)会通过套接字(Unix Domain 或 TCP/IP)向应用程序发出窗口活动的通知,因为客户端就是通过这种机制与 X 服务器交互的。如果我们决定要实现基于内部的socketpair(2)函数的跨线程事件的派发,那么窗口的管理活动需要唤醒的是:

  • 套接字 socket
  • 定时器 timer

这也正是select(2)系统调用所做的:它监视窗口活动的一组描述符,如果在一定时间内没有活动,它会发出超时消息(这种超时是可配置的)。Qt 所要做的,就是把select()的返回值转换成一个合适的QEvent子类的对象,然后将其放入事件队列。好了,现在你已经知道事件循环的内部机制了。

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

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

有了事件循环,你就会想怎样阻塞它。阻塞它的理由可能有很多,例如我就想让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对象还在队列中),也不会有其它什么交互发生(还是同样的原因),定时器也不会超时并且网络交互会越来越慢直到停止。也就是说,前面我们大费周折分析的各种依赖事件循环的活动都会停止。这时候,需要窗口管理器会检测到你的应用程序不再处理任何事件,于是告诉用户你的程序失去响应。这就是为什么我们需要快速地处理事件,并且尽可能快地返回事件循环。

现在,重点来了:我们不可能避免业务逻辑中的耗时操作,那么怎样做才能既可以执行那些耗时的操作,又不会阻塞事件循环呢?一般会有三种解决方案:第一,我们将任务移到另外的线程(正如我们上一章看到的那样,不过现在我们暂时略过这部分内容);第二,我们手动强制运行事件循环。想要强制运行事件循环,我们需要在耗时的任务中一遍遍地调用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来达到这一目的:当网络响应完成时,这个局部的事件循环也会退出。

前面我们也强调过:通过“其它的入口”进入事件循环要特别小心:因为它会导致递归调用!现在我们可以看看为什么会导致递归调用了。回过头来看看按钮的例子。当我们在Worker::doWork()槽函数中调用了QCoreApplication::processEvents()函数时,用户再次点击按钮,槽函数Worker::doWork()又一次被调用:

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

第一次调用

QCoreApplication::processEvents() // 

手动发出所有事件

[…]
QWidget::event(QEvent * ) // 

用户又点击了一下按钮…

Button::mousePressEvent(QMouseEvent *)
Button::clicked() // 

又发出了信号…

[…]
Worker::doWork() // 

递归进入了槽函数!

当然,这种情况也有解决的办法:我们可以在调用QCoreApplication::processEvents()函数时传入QEventLoop::ExcludeUserInputEvents参数,意思是不要再次派发用户输入事件(这些事件仍旧会保留在事件队列中)。

幸运的是,在删除事件(也就是由QObject::deleteLater()函数加入到事件队列中的事件)中,没有这个问题。这是因为删除事件是由另外的机制处理的。删除事件只有在事件循环有比较小的“嵌套”的情况下才会被处理,而不是调用了deleteLater()函数的那个循环。例如:

QObject *object = new QObject;
object->deleteLater();
QDialog dialog;
dialog.exec();

这段代码并不会造成野指针(注意,QDialog::exec()的调用是嵌套在deleteLater()调用所在的事件循环之内的)。通过QEventLoop进入局部事件循环也是类似的。在 Qt 4.7.3 中,唯一的例外是,在没有事件循环的情况下直接调用deleteLater()函数,那么,之后第一个进入的事件循环会获取这个事件,然后直接将这个对象删除。不过这也是合理的,因为 Qt 本来不知道会执行删除操作的那个“外部的”事件循环,所以第一个事件循环就会直接删除对象。

35 评论

joinAero 2013年12月22日 - 15:09

“用户再次点击按钮,槽函数Worker::doWork()有一次被调用:”

有一次 >> 又一次

回复
jorneyr 2014年1月13日 - 07:35

可重入的(Reentrant):如果一个类允许多个线程使用它的多个实例,并且保证在同一时刻至多只有一个线程访问同一个实例,就称这个类是可重入的。

Reentrant的解释不对。

回复
Charles 2015年4月12日 - 21:13

I agree with you. 我一看是看了半天中文解释不理解,中文翻译确实有问题。总结了下,可重入与线程安全的最主要区别在于访问的数据是否共享。数据共享:线程安全,数据独立:可重入。我记得可重入函数一定线程安全。

回复
jorneyr 2014年1月13日 - 07:36

线程安全(Thread-safe):如果多个线程可以在同一时刻使用一个类的对象,就说这个类是线程安全的。如果多个线程可以在同一时刻访问函数的共享数据,就称这个函数是线程安全的。

线程安全的解释也不对哦。

回复
xuwenping 2014年3月8日 - 23:28

豆豆哥,想问一下里面说的「返回事件循环」的意思是说 event函数返回true吗?

回复
豆子 2014年3月10日 - 10:59

个人理解,返回事件循环就是函数直接返回,不要堵塞,并不一定要 event 函数返回 true。比如如果函数中有一个复杂的操作,耗时很长,我们就说不会立刻返回事件循环。

回复
xuwenping 2014年3月20日 - 22:45

谢谢豆子哥,前天回的既然没反应。。。。

回复
Puzzle 2014年4月14日 - 23:59

“如果一个类允许多个线程使用它的多个实例,并且保证在同一时刻至多只有一个线程访问同一个实例,就称这个类是可重入的。”这个定义是不是有问题?

回复
豆子 2014年4月15日 - 11:02

已经按照最新文档修改过了,多谢指出

回复
datde 2014年6月4日 - 18:29

这时候,需要窗口管理器会检测到你的应用程序不再处理任何 时间,
应该为 事件吧

回复
豆子 2014年6月5日 - 09:44

多谢指出,已经修改过来

回复
bin 2014年7月20日 - 10:06

QObject *object = new QObject;
object->deleteLater();
QDialog dialog;
dialog.exec();
你的这篇文章中上述object指针不会造成野指针,
但是http://qt-project.org/wiki/ThreadsEventsQObjects_Chinese这篇文章却在此注释为野指针,这哪个是正确的版本?

回复
豆子 2014年7月20日 - 15:59

这篇文章使用的版本是 http://qt-project.org/wiki/ThreadsEventsQObjects,由于你给出的中文版是 2011 年的版本,而英文版是 2013 年的,所以怀疑文档有了更新。应该按照最新版本的文档为准。

回复
gebilaowang 2014年10月17日 - 10:39

博客打开有点慢。

回复
豆子 2014年10月19日 - 10:24

现在主机在国外,计划年底迁移到国内,到时候应该可以快一些。另外的原因是 google font 的问题,这个已经处理,不知道现在效果如何。

回复
果果冻 2015年2月3日 - 22:06

你好,我现在在学习使用自定义事件,EVENT_KEYRESPONSE =QEvent::User,
QEvent *KeyResponse =new QEvent(static_cast(EVENT_KEYRESPONSE));
bool Widget::event(QEvent *e)
{
switch (e->type())//处理感兴趣的事
{
case EVENT_KEYRESPONSE://postEvent
KeyResponse();
break;
case EVENT_ANALYSIS://sendEvent
Analysis();
break;
case EVENT_CONFIRM:
Confirm();
break;
default:
break;
return true;//返回true表示已经对此事件进行了处理
}

return QWidget::event(e);
}
在调用postEvent是使用QApplication::postEvent(this,Analysis);其中this是QWidget类的实例,可是QApplication::postEvent(this,Analysis);只能在构造函数中使用,在其他的函数中调用时,总是提示错误:SpectrumAnalyzerDlg.cpp:292: error: no matching function for call to ‘QApplication::postEvent(CSpectrumAnalyzerDlg* const, )’ 您知道怎么解决呢?

回复
豆子 2015年2月6日 - 11:04

从代码片段中看不出问题,或者可以将完整代码发送到邮箱:devbean#outlook.com

回复
小怪 2016年5月4日 - 14:48

博主好
在读了你相关线程介绍时候,又回来翻看了事件和信号槽相关内容
有几个问题和不确定的地方向你请教:
当鼠标点击时候 并不是直接触发了click()信号
而是点击鼠标到最后槽函数响应是不是要经过这么一个流程:窗口发出事件event,然后由Qt转化为Qevent事件,然后通过Qt事件机制传到事件响应函数 最后在响应函数中国触发一个click()信号 激活槽函数。
如果是这么一个流程。那么信号槽机制的源头信号还是要通过事件机制来激活。
在事件总结和线程中都提到了QApplication::notify()函数。这个函数应该在时间循环中有很重要作用。这个函数是不是就是事件队列中的事件分发器的角色
我还很疑惑的是 如果槽函数执行一个长时间任务。为什么会阻塞主线程。因为我感觉事件响应函数只是发出了一个信号,又不负责执行任务,发出信号后 应该继续向下执行 返回return值。既然有返回值 那么线程是不是就不会被阻塞。

回复
豆子 2016年5月7日 - 21:35

QApplication::notify() 的作用就是分发事件。信号发出时,槽函数执行长时间任务,会占用大量系统资源,因此会阻塞主线程。注意槽函数的执行并不是自动开启另外一个线程的。

回复
小怪 2016年5月5日 - 10:10

博主 你好 又来讨教问题
关于事件循环问题
长时间任务阻塞事件循环的情况我们可以重新开启一个事件循环 (向博主确认一下该循环是不是用来响应UI界面中的事件)。那么两个循环之间关系是什么 嵌套关系吗 是不是内层循环开启会阻塞外层循环。
开启新的循环后 主线程上就有两个任务:长时间任务(这个任务是不是应该在外层循环中)和新的循环。他们之间是如何切换的

回复
metalmac 2016年5月12日 - 14:30

感谢博主的付出,文字浅显易懂,特别在有过相关开发经历后体会更加深刻,不过Qt放了很久现在捡起来又碰到如下问题,请问如何解决
我定义了个QUdeServer类,继承自QThread(之前直接继承自QObject),类里定义一个QUDPSocket的指针类型,然后在构造函数里初始化 udpSocket= new QUdpSocket(this);

在main()函数里 声明并定义QUdeServer类型指针QUdeServer *udpServer= new QUdeServer(); 就出现以下问题;

QObject: Cannot create children for a parent that is in a different thread.
(Parent is QObject(0xbb6018), parent's thread is QThread(0xbb6270), current thre
ad is QThread(0xbc5760)

回复
metalmac 2016年5月12日 - 15:44

另外,补充一点,我在main()函数中使用moveToThread改变线程依附性了,然后启动子线程

udpServer->moveToThread(udpServer);
udpServer->start();

回复
Kian 2016年7月19日 - 22:42

请问豆子
在主线程中,由QCoreApplication::exec()开启了事件循环,然后在调用QDialog::exec(),那么所有的事件循环将暂时由QDialog::exec()中创建的QEventLoop来管理。因为单线程不能并行执行。那么如果继承一个QThread类并且重写了run函数,在run函数中开启了事件循环,那么子线程和主线程的事件循环是并行执行的吗

回复
豆子 2016年7月19日 - 23:17

QThread 启动之后有自己的事件循环,一般不需要创建 QEventLoop。但是,只有在 QThread::run() 函数中创建的事件和信号才会在 QThread 事件循环中被处理。所以,如果你用 QCoreApplication::postEvent(thead, event) 这种方法发送事件,由于 thread 对象是在主线程创建,所以事件还是在主线程处理。继承 QThread 的话比较麻烦,更简单的方法是创建一个 QObject 对象,然后使用 moveToThread() 移动到子线程,将事件发送到这个对象就可以了。

回复
Kian 2016年7月20日 - 14:44

也就是说,在run()中调用exec()开启的循环依然时属于主线程的,此时循环时嵌套执行。除非在run()中新建一个QEventLoop才是属于新线程自己的循环,而此时主线程循环和子线程循环完全是并行执行的?

回复
豆子 2016年7月20日 - 21:29

我觉得并不需要单独创建 QEventLoop,每个线程已经有自己的事件循环了(不知道这样理解对不对),真正需要注意的是 postEvent 和 sendEvent 这样的函数,第一个参数是接收这个事件的对象,这个对象必须是在哪个线程创建,事件就会在哪个线程处理。所以,如果你的事件需要在线程中处理,就需要保证这个对象是由线程创建。

回复
Kian 2016年7月21日 - 19:58

还有一个问题。
在执行doWork这个槽的过程中,主循环阻塞,不在分发事件。文中提到一种创建QEventLoop并调用QEventLoop::exec启动局部循环的方法。局部循环不会阻塞,继续分发事件。
然而QApplication::exec内部实际上也是通过调用QEventLoop::exec启动主循环的。
同样使用QEventLoop::exec启动循环,为什么主循环会堵塞,局部循环不会堵塞呢?

豆子 2016年7月22日 - 09:23

这里说的“不会阻塞”,是指不会阻塞整个应用程序,只会在调用 QEventLoop 的局部阻塞

luis 2016年7月27日 - 14:24

博主 你好 又来讨教问题
关于事件循环问题
长时间任务阻塞事件循环的情况我们可以重新开启一个事件循环 (向博主确认一下该循环是不是用来响应UI界面中的事件)。那么两个循环之间关系是什么 嵌套关系吗 是不是内层循环开启会阻塞外层循环。
开启新的循环后 主线程上就有两个任务:长时间任务(这个任务是不是应该在外层循环中)和新的循环。他们之间是如何切换的

回复
豆子 2016年7月28日 - 11:41

长时间任务是开启一个子线程,将这个任务放到子线程去执行,主线程依旧处理 UI 相关操作。两个线程之间使用信号槽传递信息。一个简单的应用是,你需要一个无限循环等待用户上传数据,如果在主线程开启循环,界面就会阻塞,在子线程的话就不会有这个问题

回复
LinWM 2017年7月24日 - 17:35

在“Button点击doWork()分析”中存在以下问题,希望豆子老大答疑解惑:1.按钮点击为什么要重写mousePressEvent()发出一个clicked()信号,不是按钮点击都会发出clicked信号;2.后面说的“等待事件处理函数返回,导致阻塞”,暗示的是“clicked信号的发射和doWork()槽的调用”是同步进行的吗?

回复
豆子 2017年7月25日 - 09:48

1. 这里只是说,如果你需要重写 Button 的 mousePressEvent() 函数,不要忘记发出 clicked() 信号;否则你重写的 Button 就不会发出这个信号了。当然,如果你不重写这个函数,也就不需要这种处理了。
2. 默认情况下,只要 sender 和 receiver 在同一线程,信号和槽就会在同一线程,不会新建线程处理。

回复
LinWM 2017年7月24日 - 18:13

“另外一种解决方案我们在前面的章节提到过:使用QEventLoop类重新进入新的事件循环。通过调用QEventLoop::exec()函数,我们重新进入新的事件循环,给QEventLoop::quit()槽函数发送信号则退出这个事件循环”:这个方法细读前面的章节,QNetworkReply本身是异步的,严格上我觉得它不会阻塞线程,所以该方法不是那么立竿见影。

回复
豆子 2017年7月25日 - 09:53

我测试该方法的确会将 QNetworkReply 转换成同步;应该说是看起来像同步。它实质上是让网络在异步进行,只是界面用一个线程给阻塞掉,因此用户看起来是同步的。

回复
Thorn 2020年12月24日 - 12:04

想请问一下博主,button的例子,如果在dowork开启一个局部循环QEventLoop,多次点击,会造成递归调用吗

回复

发表评论

关于我

devbean

devbean

豆子,生于山东,定居南京。毕业于山东大学软件工程专业。软件工程师,主要关注于 Qt、Angular 等界面技术。

主题 Salodad 由 PenciDesign 提供 | 静态文件存储由又拍云存储提供 | 苏ICP备13027999号-2