到多线程事件分发)
刚接触Qt那会儿我对main()里那行app.exec()的态度就是范例代码的一部分照着抄完事。后来在一次按键处理里写了个while(1)做轮询界面直接卡成白板我才被迫去琢磨事件循环与处理机制到底在做什么。那次之后我把“界面无响应是不是事件循环被堵了”当成了排查Qt问题时的第一直觉。这篇文章算是一份面向自己的概念和流程总结把事件循环从哪里来、事件怎么分发、哪些操作会踩雷、多线程下怎么配合串成一条脉络适合正在学Qt事件机制、或者在调试“界面假死”和“槽不执行”这类问题的朋友当作参考。1. app.exec() 打开的那个盒子事件队列与调度本质1.1 exec() 不是“空转死循环”而是带队列的调度器很多人第一次听到“事件循环”这四个字容易脑补成一个疯狂的while(1)在拿CPU换时间。实际上Qt的事件循环是一个带队列的调度器核心结构可以理解为三件套事件队列、事件源、事件分发器。QCoreApplication::exec()启动后线程就进入了一个循环这个循环反复做下面这件事从当前线程的事件队列里取一个事件交给QCoreApplication::notify()再由notify()转到具体QObject的event()函数。处理完一个再取下一个。这个“取一个、处理一个、再取一个”的过程就是事件循环最朴素的模型。有个很容易被忽略的点事件循环在队列空的时候并不会空转。它会进入等待状态由操作系统的事件机制在“有新事件到来”时唤醒它。所以你开着Qt空窗口CPU占用接近零是因为循环睡了而不是它在后台拼命地空转。我见过一些人拿QThread::currentThread()-msleep()模拟“事件循环转一圈”这是完全错误的做法。睡眠会阻塞当前线程事件循环一旦被阻塞队列里的重绘事件、鼠标键盘事件全都无从处理表现出来就是界面卡死。1.2 事件从哪里来又到哪里去要搞懂事件循环得先明确“事件”是什么。Qt里一切事件都继承自QEvent它和“信号槽”是两条不同的机制事件通常来自外部系统或底层通知比如鼠标按下、键盘输入、窗口重绘、定时器超时而信号槽是QObject层面的调用抽象。只不过在跨线程场景下信号槽投递会借助事件队列实现这是后话。事件源大致可以分为几类窗口系统事件鼠标、键盘、触摸、窗口移动和缩放、绘制请求等由平台相关代码转换为QMouseEvent、QKeyEvent、QPaintEvent等。定时器事件QTimer到期后不会凭空调用你的槽而是先向事件队列投递一个QTimerEvent由事件循环分发给对应的对象。网络与Socket事件QSocketNotifier监听到可读可写时也会产生事件。自定义事件你自己QCoreApplication::postEvent()投递的任意事件。跨线程队列连接信号槽在AutoConnection模式下跨线程触发时会编码成一个事件投递到接收线程。分发路径是固定的事件从队列出来经过QCoreApplication::notify()最终到达接收者的event()然后由event()根据类型转给对应的处理函数比如mousePressEvent()、keyPressEvent()。你说“点击了按钮”完整的链路是系统产生鼠标事件 → 事件队列 → 事件循环取出 →notify()→ 按钮的event()→mousePressEvent()。这也是为什么很多调试技巧都建议在event()里打印日志因为所有事件都从这里过。1.3 事件队列不是全局唯一的还有一个关键概念每个线程都有自己的事件队列和事件循环。很多人以为事件队列是进程级的这不对。QCoreApplication::exec()启动的是main线程的事件循环而QThread里如果run()默认实现它内部也有一个exec()给工作线程建立自己的事件循环。后面讲多线程时会详细说这里先记下“线程”和“事件循环”是一一配对的关系主线程的事件循环堵了不代表工作线程的事件循环也堵了反之亦然。2. exec() 可以嵌套局部事件循环与重入风险2.1 QEventLoop在主循环里再开一个小循环QCoreApplication::exec()启动的是全局事件循环而QEventLoop则允许你在任何代码处启动一个局部事件循环。局部事件循环的模型是“嵌套”的——外层的事件循环暂时让位内层exec()继续取事件分发直到内层quit()才返回外层。最常见的代码是同步等待某个异步结果QEventLoop loop; connect(networkManager, QNetworkReply::finished, loop, QEventLoop::quit); loop.exec(); // 到这里reply 已经 finished这段代码把控制权交给了一个局部事件循环网络响应没回来loop.exec()就不返回。期间界面不会卡死因为局部事件循环也在正常分发事件。这个模式在很多想把异步改同步的项目里都用过但它有个明显的副作用代码重入。2.2 模态对话框的exec()也是嵌套循环QDialog::exec()、QMenu::exec()内部都会启动局部事件循环。这也是为什么QDialog::exec()能“阻塞”调用位置却仍然让窗口响应、不冻结整个程序。它并不是简单地阻塞线程而是启动了一个新的嵌套事件循环来维持事件分发。嵌套事件循环带来的坑比较隐晦。比如对话框exec期间外层循环暂停了但局部循环还在处理用户输入因此可能触发一些当前函数栈之外的代码。典型场景在一个按钮槽里弹模态框用户点模态框里的按钮时可能再次触发了同一个对象的另一个槽而这个槽又在同一段逻辑里操作同一个成员变量造成不可预期的重入。我踩过的例子是在主窗口的某个槽里dialog.exec()对话框里有个按钮会emit一个信号连接到主窗口同一个对象导致主窗口槽函数在执行到一半时再次被调用成员变量处于中间状态。现在我在项目里对“exec()里可能触发哪些信号”会格外小心尽量不让模态exec和当前对象产生复杂的信号联系或者在入口处加一个独立的状态标记。2.3 嵌套循环的退出控制嵌套事件循环的退出也要对应清楚。QEventLoop::quit()只退出当前这一个局部循环如果你在嵌套了三层exec()的地方只quit()一次外层那两层不会自动退。常见误用是在槽函数里调qApp-quit()它只能退出主事件循环对局部循环无能为力。排查这类问题时可以借助QEventLoop::loopLevel()内部接口或者直接打印日志观察exec()的配对情况。我自己的习惯是任何局部exec()都要配一个明确的退出信号并且考虑超时出口否则某个信号漏发整个UI就会失去响应。比如等待网络响应至少要加一个定时器兜底QEventLoop loop; QTimer::singleShot(3000, loop, QEventLoop::quit); connect(reply, QNetworkReply::finished, loop, QEventLoop::quit); loop.exec();这样即使网络异常也不会永久卡在局部事件循环里。3. 事件处理的中枢event()、过滤器与处理器的优先级3.1 event() 是每个 QObject 的总入口每个QObject都可以重写event()它是事件到达对象后的第一站。event()负责看事件类型然后决定转给谁。拿QWidget举例它继承自QObject和QPaintDeviceQWidget::event()会把QMouseEvent转给mousePressEvent()把QKeyEvent转给keyPressEvent()把QPaintEvent转给paintEvent()。如果你想在分发之前统一拦截可以重写event()bool MyWidget::event(QEvent *e) { if (e-type() QEvent::KeyPress) { // 处理自定义快捷键逻辑 return true; } return QWidget::event(e); }注意这个返回值的语义true表示事件已经被处理不再往下分发false通常来自基类调用则继续默认流程。很多人重写event()后忘了调用基类导致一大堆事件莫名其妙消失这是排错时最先要检查的。3.2 事件过滤器不继承也能半路截获事件过滤器是可在任意QObject上安装的拦截机制。目标对象上调用installEventFilter()之后所有发给它的事件都会先经过过滤器对象的eventFilter()。bool Manager::eventFilter(QObject *target, QEvent *e) { if (target m_edit e-type() QEvent::KeyPress) { QKeyEvent *ke static_castQKeyEvent *(e); if (ke-key() Qt::Key_Return) { // 拦截回车不进入QLineEdit return true; } } return QObject::eventFilter(target, e); }过滤器的调用顺序是后安装的先执行多个过滤器形成一个链只要有一个返回true后续过滤器就不会再收到这个事件。注意过滤器的执行时机早于目标对象的event()也就是说它能对事件做“预处理”这是它与重写event()最大的区别。实战里我常用过滤器做全局快捷键拦截、统一校验输入、或者在某控件不想继承的情况下临时禁止某些事件。相比重写控件子类过滤器更灵活不需要为每个控件单独建类。3.3 sendEvent 与 postEvent两条完全不同的路径向对象发送事件有两种方式很多人以为只是接口差别实际上它们的语义差别很大。QCoreApplication::sendEvent()是同步的。它直接把事件交给notify()事件处理完才返回返回值是事件处理后的结果。适合你想立刻把事件派发给某个对象并拿到结果的时候也常用于测试环境模拟输入。QCoreApplication::postEvent()是异步的。它把事件追加到目标对象所在线程的事件队列立即返回不关心事件何时被处理。因为事件队列持有指针所以传递给postEvent()的对象必须用new创建不能用栈对象。对比项sendEventpostEvent触发时机立即同步处理进入队列事件循环处理返回值有事件处理结果无对象生命周期可以传递栈对象必须new典型场景模拟输入、测试跨线程通知、定时器、自定义事件自定义事件我用postEvent比较多它适合“通知但不强制立刻处理”的场景。要注意postEvent()之后如果目标对象被销毁事件还没处理可能导致野指针崩溃。保险的做法是销毁对象前调用QCoreApplication::removePostedEvents(obj)清理队列里属于它的所有事件。这个细节在一些“退出时崩溃”的问题里出现过。4. 两个最常见的“事件循环杀手”同步阻塞与 deleteLater4.1 为什么 sleep 会让界面变成白板主线程里的一切绘制和交互都依赖事件循环持续运转。如果你在主线程槽函数里写QThread::sleep(1)这一秒内线程睡着了事件循环停摆队列里的QPaintEvent一直不被处理窗口就白屏或失去响应。鼠标键盘事件也都压着没反应。这不是偶发而是必然。事件处理是单线程顺序执行的同一个线程在任意时刻只能做一件事。你在槽里占着线程事件队列里的活儿就得等着。那“延迟几秒再做某件事”怎么写才对经验做法是// 错误卡死界面 QThread::sleep(3); doSomething(); // 正确非阻塞的延迟调用 QTimer::singleShot(3000, this, doSomething);如果确实需要“同步等一会但又不卡界面”可以借用局部事件循环QEventLoop loop; QTimer::singleShot(3000, loop, QEventLoop::quit); loop.exec();这种写法在等待期间界面仍然响应因为局部事件循环在分发事件。但别忘了它也是嵌套循环同样有重入风险不能到处滥用。我个人会优先考虑QTimer或把耗时任务丢到工作线程而不是在同一线程里强行“等”。4.2 deleteLater 执行时机的硬约束deleteLater()是Qt里非常优雅的设计它不立刻删除对象而是向对象投递一个DeferredDelete事件等事件被处理时真正销毁对象。这样能避免“对象在事件处理中途被delete导致崩溃”。但这个设计有个硬前提对象所在线程必须有事件循环在运行。如果对象所在线程压根没有事件循环或者事件循环正被某个死循环卡住DeferredDelete事件永远不会被处理对象就一直活到地老天荒内存泄漏就出现了。我遇到过一个典型场景工作线程的run()重写后没有调用exec()直接在里面跑自定义业务循环业务结束时deleteLater()一个对象结果对象一直不释放。后来改成在线程末尾调用quit()或者干脆直接delete才解决。排查时只要知道一个原则就行deleteLater()不是万能的它依赖事件循环返回事件分发点的那一瞬间。还有一个常被忽视的点如果你在同一个事件循环里连续处理多个事件deleteLater()的对象通常要等当前事件处理结束、控制权回到事件循环时才被回收。也就是它不会在当前事件内立刻生效。所以别在同一个槽里deleteLater()之后立刻访问这个指针那仍是悬垂引用。4.3 事件处理期间 delete 当前对象有些人图省事在槽函数里直接delete this;这在大多数情况下会引出很阴险的崩溃。因为当前槽函数本身就是在响应某个事件事件对象、调用栈可能还引用了这个对象的成员数据直接删除会使后续函数返回时跳到非法内存。正确做法是改用deleteLater()让Qt在安全的时间点完成销毁。尤其在对象自己有事件处理逻辑、信号连接密集的情况下deleteLater()比直接删除稳得多。不过前面也提醒了前提是事件循环正常。两者配合起来用才不容易出事。5. 多线程下的事件循环每个线程都有自己的一摊事5.1 线程默认不使用事件循环也“能跑”但信号槽很受影响QThread::run()默认实现是调用exec()也就是说一个普通的QThread::start()启动后工作线程里默认有一个事件循环。但如果你重写了run()却没在里面调用exec()那这个工作线程就没有事件循环。没有事件循环意味着什么它不能处理postEvent()投递给它的事件也不能响应跨线程队列连接的信号槽。我在项目里见过这样的写法run()里放一个while(condition){...}然后把一个对象的moveToThread(workerThread)指望信号槽能跑到工作线程里执行。结果槽一直在主线程跑或者根本不执行。原因就在于此工作线程没有事件循环队列事件送不过去。5.2 moveToThread 之后的信号槽依赖事件循环投递跨线程信号槽的机制值得单独说。当信号和槽位于不同线程且连接方式为AutoConnection时Qt会把参数打包为事件投递到槽函数所在线程的事件队列再由那边的事件循环取出并执行槽函数。这个设计带来两个结论第一目标线程必须有事件循环且循环要正常运转否则槽函数永远不会被调用。 第二槽函数执行时它是运行在目标线程里而不是发送信号的线程里。我在一个音频采集项目里踩过很典型的坑主线程发信号给工作对象的槽工作对象已经moveToThread到了采集线程但采集线程的run()重写后没有调用exec()导致槽函数从未执行采集流程像假死一样。后来我把业务逻辑改成在run()里启动事件循环问题立刻消失了。5.3 线程退出时的事件循环清理多线程事件循环的结束也要处理干净。QThread::quit()会请求当前事件循环退出wait()则等待线程真正结束。尽量不要用terminate()那是在线程不知道去哪儿、可能持锁的情况下强行结束轻则资源泄漏重则进程崩溃。一个还算稳妥的退出流程是设置一个退出标志或者直接调用quit()停止事件循环。如果需要再调用wait()等待线程回收。工作对象不要在线程结束后还留在那个线程上下文里最好moveToThread(qApp-thread())后再删除。有些代码会在finished信号里直接delete worker但要注意这个信号是主线程收到的还是工作线程收到的。跨线程删除对象同样要小心。我建议在退出前先确认所有队列事件都已消化再决定deleteLater()还是同步删除避免退出顺序混乱导致“对象已销毁事件还在队列里”的崩溃。5.4 调试事件循环问题的几条经验这几条是我多次排查“假死、不响应、槽不执行”之后沉淀出来的先在主线程里搜while、sleep、exec()嵌套看是否有同步阻塞占用事件循环。在对象的event()和eventFilter()里加线程ID与事件类型日志确认事件到底有没有到达目标对象。遇到跨线程槽不执行先确认接收者线程的事件循环是否还活着而不是一上来就怀疑信号没发出。遇到莫名的崩溃检查是否在事件处理期间直接删除了对象或者postEvent传入的指针在事件处理前被释放。这些经验看着朴素但绝大多数事件循环相关的问题最后都能归到“队列里的事件根本没机会被处理”这一个根因上。我对事件循环的态度从最初的“照抄exec()”到后来“任何卡顿先问自己这个线程现在还有没有能力处理事件”转变是踩了不少坑换来的。每次遇到界面假死、槽函数不执行、对象迟迟不释放我都会回到这条主线去推演事件在哪个环节被堵住了。能把这条链路想清楚Qt一半的疑难杂症你都知道该往哪个方向查了。