Qt中正确的线程

Qt是一个非常强大且方便的C ++框架。 但是这种便利性有一个缺点:Qt中发生的许多事情都对用户隐藏了。 在大多数情况下,Qt中的相应功能可以“神奇地”工作,并且可以教会用户简单地将此魔术视为理所当然。 然而,当魔术破裂时,很难识别并解决突然出现在看似平坦的水平上的问题。

本文试图系统化Qt如何在“幕后”实施工作流,以及与此模型的局限性相关的许多明显的陷阱。

基础知识
线程关联,初始化及其限制
主线程,QCoreApplication和GUI
渲染线程
结论

基础知识


让我们从基础开始。 在Qt中,任何能够处理信号和时隙的对象都是QObject类的后代。 这些对象在设计上是不可复制的,并且在逻辑上表示彼此“交谈”的某些单个实体-对某些事件做出反应并可以自己生成事件。 换句话说,Qt中的QObject实现了Actors模式 。 如果正确实施,那么任何Qt程序本质上都只是QObjects相互交互的网络,其中所有程序逻辑都“存在”。

除了一组QObject,Qt程序还可以包含数据对象。 这些对象无法生成和接收信号,但可以被复制。 例如,您可以比较它们之间的QStringList和QStringListModel。 其中一个是QObject,不可复制,但可以直接与UI对象进行交互,另一个是常规的可复制数据容器。 反过来,带有数据的对象又分为“ Qt元类型”和所有其他类型。 例如,QStringList是Qt元类型,但是std :: list <std:string>(没有其他手势)不是。 前者可以在任何Qt-shnom上下文中使用(通过信号传输,位于QVariant等),但是需要特殊的注册过程,并且该类必须具有公共析构函数,复制构造函数和默认构造函数。 第二种是任意的C ++类型。

无缝移动到实际线程


因此,我们有条件的“数据”,并且有条件的“代码”可以使用它们。 但是谁真正执行此代码? 在Qt模型中,此问题的答案是明确设置的:每个QObject都严格地绑定到某个QThread线程,而该线程实际上是在为该对象的插槽和其他事件服务。 一个线程可以一次服务多个QObject,也可以根本不服务,但是QObject始终有一个父线程,并且始终是一个。 实际上,我们可以假设每个QThread“拥有”一组QObject。 在Qt术语中,这称为“线程亲和力”。 让我们尝试可视化以便清晰:



每个QThread内都有一个消息队列,发往该QThread“拥有”的对象的消息。 在Qt模型中,假设如果我们希望QObject采取某种行动,我们会向该QObject“发送” QEvent消息:

QCoreApplication::postEvent(QObject *receiver, QEvent *event, int priority); 

在此线程安全的调用中,Qt查找接收者对象所属的QThread,将QEvent写入此线程的消息队列,并在必要时唤醒此线程。 预期此后在此QThread中运行的代码将从队列中读取消息并执行相应的操作。 为此,QThread中的代码必须进入QEventLoop事件循环,创建适当的对象,然后将其调用exec()方法或processEvents()方法。 第一个选项进入无限消息处理循环(在QEventLoop接收quit()事件之前),第二个选项仅限于处理先前在队列中累积的消息。



显而易见,属于一个线程的所有对象的事件都是按顺序处理的。 如果线程对事件的处理花费很长时间,则所有其他对象将被“冻结”-它们的事件将累积在流的队列中,但不会被处理。 为了防止这种情况的发生,Qt提供了协作多任务处理的可能性-任何地方的事件处理程序都可以通过创建新的QEventLoop并将控制权传递给它来“临时中断”。 由于先前也从流中的QEventLoop中调用了事件处理程序,因此使用这种方法,形成了相互“嵌套”的事件循环链。

关于事件分派器的几句话
严格来说,QEventLoop只是对一个较低级的依赖于系统的,称为Event Dispatcher的原语进行用户友好的包装,并实现了QAbstractEventDispatcher接口。 是他执行事件的实际收集和处理。 一个线程只能有一个QAbstractEventDispatcher,并且只能安装一次。 除其他外,从Qt5开始,这使您可以通过在流的初始化中仅添加一行,而无需触及可能使用QEventLoop的众多位置,从而在必要时轻松地用更合适的调度器替换调度程序

在这样一个周期中处理的“事件”的概念包括什么? 对于所有Qt员工来说,“信号”只是一个特殊的示例,即QEvent :: MetaCall。 这样的QEvent存储一个指针,该指针指向标识需要调用的函数(插槽)及其参数所必需的信息。 但是,除了Qt中的信号外,还有大约一百(!)个其他事件,其中十二个是为特殊Qt事件保留的(ChildAdded,DeferredDelete,ParentChange),其余对应于来自操作系统的各种消息。

为什么其中有这么多?为什么没有信号就不可能做?
读者可能会问:为什么会有那么多事件,为什么仅凭一种方便且通用的信号机制就不可能解决? 事实是,不同的信号可以被非常不同地处理。 例如,某些信号是可压缩的-如果队列中已经有一个这种类型的原始消息(例如QEvent :: Paint),则随后的消息只需对其进行修改。 其他信号可以被过滤掉。 少量标准且易于识别的QEvents的存在大大简化了相应的处理过程。 此外,由于设备明显更简单而导致的QEvent处理通常比处理类似信号要快一些。

这里一个明显的陷阱是,在Qt中,通常来说,一个流甚至可能没有Dispatcher,因此没有单个EventLoop。 属于此流的对象将不会响应发送给它们的事件。 由于默认情况下QThread :: run()调用QThread :: exec()来实现标准EventLoop,因此通常尝试确定自己版本的run()的人都将从QThread继承而来。 QThread的类似用例原则上是相当有效的,甚至在文档中也建议使用,但它与上述Qt中组织代码的一般思想背道而驰,通常无法按用户期望的那样工作。 在这种情况下,一个典型的错误是试图通过调用QThread :: exit()或quit()来停止这种自定义QThread。 这两个函数都向QEventLoop发送消息,但是如果流中根本没有QEventLoop,那么自然就没有人可以处理它们。 结果,缺乏经验的用户试图“修复损坏的类”开始尝试使用“工作的” QThread ::终止,这是绝对不可能的。 请记住-如果重新定义run()并且不使用标准事件循环,则必须提供一种自行退出线程的机制-例如,为此使用特殊添加的QThread :: requestInterruption()函数。 但是,如果您真的不打算实现某些特殊的新型线程,或者使用专门为此类脚本创建的QtConcurrent,或者将逻辑放入从QObject继承的特殊Worker对象中,然后将其放入标准QThread中并进行管理,则最好不直接继承自QThread。工人使用信号。

线程关联,初始化及其限制


因此,正如我们已经知道的,Qt中的每个对象都“属于”某个流。 同时,出现了一个逻辑问题:实际上,到底是哪个? Qt接受以下约定:

1.任何“父母”的所有“孩子”始终与父母同住

这也许是Qt流模型最强大的限制,而试图打破它通常会给用户带来非常奇怪的结果。 例如,尝试对驻留在Qt中另一个线程中的对象进行setParent尝试仅会失败(将警告写入控制台)。 显然,之所以达成了这种妥协,是因为这样的事实,即如果在另一个线程中生活的父母死亡,线程安全地删除“孩子”是非常重要的,并且容易捕获错误。 如果要实现生活在不同流中的交互对象的层次结构,则必须自己组织删除。

2.在创建期间未指定其父对象的对象位于创建它的流中

在同一时间,简单且同时出现的所有内容并不总是显而易见的。 例如,根据此规则,QThread(作为对象)与它自己控制的线程位于不同的线程中(并且根据规则1,它不能拥有在此线程中创建的任何对象)。 或者说,如果重新定义QThread :: run并在其中创建任何QObject后代,则无需采取特殊措施(如上一章所述),创建的对象将不会响应信号。

如有必要,可以通过调用QObject :: moveToThread来更改线程亲和力。 根据规则1,只能移动顶层“父母”(其父== null),移动任何“孩子”的尝试将被忽略。 当高层“父母”移动时,他的所有“孩子”也将移至新的流。 奇怪的是,对moveToThread(nullptr)的调用也是合法的,并且是一种创建具有“ null”线程亲缘关系的对象的方法。 这样的对象不能接收任何消息。

您可以通过调用QThread :: currentThread()函数(与该对象关联的线程)获得“当前”执行线程-通过调用QObject :: thread()

关于注意力的有趣问题
注意,对象所有权功能的实现和寻址到它们的QEvent的存储显然需要流程将相应的数据存储在某个地方。 对于Qt,QThread基类通常涉及此类数据的提取和管理。 但是,如果您在某个std ::线程中创建QObject或从该线程调用QThread :: currentThread()函数,会发生什么情况呢? 事实证明,在这种情况下,Qt隐式地“在幕后”将创建一个特殊的非所有者包装对象QAdoptedThread。 同时,用户有责任独立地确保在停止生成流的对象之前,删除该流中的所有对象。

主线程,QCoreApplication和GUI


在所有线程中,Qt肯定会挑出一个“主线程”,就UI应用程序而言,它也成为GUI线程。 QApplication对象(QCoreApplication / QGuiApplication)驻留在此线程中,该对象用于定向主要事件循环以处理来自操作系统的消息。 根据上一节中的第2条规则,实际上,“主”线程将是实际创建QApplication对象的线程,并且由于在许多操作系统中,“主线程”具有特殊含义,因此文档强烈建议使用整个第一个对象创建QApplication。 Qt程序,并在启动应用程序后立即执行(==在进程中的第一个线程内)。 要分别获得指向应用程序主线程的指针,可以使用QCoreApplication :: instance()-> thread()形式的构造。 但是,纯粹从技术上讲,QApplication 也可以挂在非main()流上 ,例如,如果Qt接口是在某种插件中创建的,并且在许多情况下可以正常工作。

由于规则“创建的对象继承当前线程”,因此您始终可以保持冷静,而不会超出一个线程的限制。 所有创建的对象将自动进入“主”线程进行服务,该线程将始终存在事件循环,并且由于没有其他线程,同步永远不会出现问题。 即使您正在使用需要多线程的更复杂的系统,大多数对象也很可能落入主流,除了少数将明确放置在其他地方的对象。 也许正是这种情况引起了看似“魔术”,在这种情况下,对象似乎可以毫不费力地独立工作(因为在流程中实现了协作式多任务处理),并且同时不需要同步,阻塞等(因为所有事情都发生在一个线程中) )

除了“主”线程是“第一个”线程并包含主QCoreApplication事件处理循环这一事实外,Qt的另一个局限性在于,与GUI连接的所有对象都必须“存在”于该线程中。 这部分是旧式的结果:由于在许多操作系统中使用GUI进行的任何操作都只能在主线程中进行,因此Qt将所有对象细分为“小部件”和“非小部件”。 窗口小部件类型的对象只能存在于主线程中,试图“压倒”此类对象在其他任何线程中都会自动爆发。 因此,甚至存在一个特殊的QObject :: isWidgetType()方法,该方法反映了处理此类对象的机制方面的深层内部差异。 但是有趣的是,在更新的QtQuick中,他们尝试使用isWidgetType摆脱拐杖,仍然存在相同的问题

怎么了 在Qt5中,QML对象不再是窗口小部件,可以在单独的线程中呈现。 但这导致了另一个问题-同步困难。 UI对象的呈现是对其状态的“读取”,并且应该是一致的:如果我们尝试在呈现对象的同时更改对象的状态,则所产生的“竞争”的结果可能不会令我们满意。 此外,构建“新”图形Qt的OpenGL非常“锐化”了这样一个事实,即绘制命令的形成是由一个具有某种全局状态的线程(即“图形上下文”)执行的,该线程只能通过一系列顺序操作来更改。 我们根本无法同时在屏幕上绘制两个不同的图形对象-它们将始终一个接一个地依次绘制。 结果,我们返回相同的解决方案-将UI分配给一个线程。 但是,细心的读者会注意到,该线程不必一定是主线程-在Qt5中,框架实际上会为此尝试使用单独的Rendering线程。

渲染线程


在新的Qt5模型的框架中,所有对象的渲染都在为此线程专门分配的渲染线程中进行。 同时,这是有道理的,并且不仅限于简单地从一个``主''流切换到另一流,这些对象被隐式分为程序员可以看到的``前端''和通常隐藏在他面前的``后端'',它们实际上执行了实际的渲染。 后端位于渲染线程中,而理论上,前端可以位于任何其他线程中。 假定前端以事件处理的形式执行有用的工作(如果有),而后端功能仅受渲染限制。 从理论上讲,这是双赢的:背面会定期“轮询”对象的当前状态并将其绘制在屏幕上,而不能因为某些对象在处理事件时“思考”过多这一事实而被“制止”另一个线程发生处理缓慢。 反过来,对象的流程无需等待图形驱动程序发出的“答案”以确认渲染完成,并且不同的对象可以在不同的流程中工作。

但是正如我在上一章中已经提到的那样,由于我们有一个创建数据的流(前面)和一个读取数据的流(后面),我们需要以某种方式进行同步。 Qt中的同步是通过锁完成的。 前部生活所在的流被临时挂起,然后进行特殊功能调用(QQuickItem :: updatePaintNode(),QQuickFramebufferObject :: Renderer :: syncnize()),其唯一任务是将与可视化相关的对象从正面复制到背面”。 在这种情况下,此类函数的调用发生在渲染线程内部 ,但由于对象此时所在的线程已停止,因此用户可以自由地使用对象数据,就像对象数据像往常一样在对象所属的流内部发生。

一切都还好吗? 不幸的是,没有,而且很明显的时刻从这里开始。 如果我们为每个对象单独获取一个锁,这将相当慢,因为渲染线程将被迫等待,直到这些对象完成其事件的处理。 对象所在的“悬挂”流是“悬挂”和渲染。 此外,当两个对象同时更改时,一个对象将在第N帧中绘制,而另一个对象仅在第N + 1帧中绘制,则“取消同步”将成为可能。 最好仅在我们确定此锁定将成功的情况下,一次对所有对象使用一次锁定。

如何解决Qt中的这个问题? 首先,决定一个窗口的所有“图形”对象都将生活在一个流中。 因此,要绘制一个窗口并锁定其中包含的所有对象,就足以单独停止该流。 其次,带有UI对象的线程会启动用于更新后端的锁,向渲染线程发送一条消息,告知需要同步并自行停止(如果有人感兴趣,请QSGThreadedRenderLoop :: PolishAndSync)。 这样可以确保渲染线程永远不会“等待”前端流。 如果突然“挂起”,则渲染线程将继续继续绘制对象的“旧”状态,而不会收到有关需要更新的消息。 这确实引起了相当有趣的错误,其形式为“如果由于某种原因渲染无法立即绘制窗口,则主线程冻结”,但是通常这是一个合理的折衷方案。 从QtQuick 2.0开始,甚至可以在渲染线程中“填充” 许多“动画”对象,因此,如果主线程被“认为”,动画也可以继续工作。



但是,此解决方案的实际结果是所有UI对象无论如何都必须驻留在同一线程中。 对于旧的小部件,在“主”线程中,对于新的Qt Quick对象,在拥有它们的QQuickWindow对象线程中。 最后一条规则被巧妙地打破了-为了绘制QQuickItem,需要将setParent设置为对应的QQuickWindow,这已经确保了对象移动到相应的流或setParent调用失败,正如已经讨论的那样。

现在,可惜的是,尽管理论上可以使用不同的QQuickWindow来存储不同的流,但是实际上这需要从操作系统向它们正确发送消息,而在Qt中,今天还没有实现。 例如,在Qt 5.13中,QCoreApplication尝试通过sendEvent与QQuickWindow通信,要求接收方和发送方在同一线程中(而不是postEvent允许线程不同)。 因此,实际上,QQuickWindow仅在GUI线程中正常工作,因此,所有QtQuick对象都位于同一位置。 结果,尽管存在渲染线程,但几乎所有可用于用户的与GUI相关的对象仍驻留在同一GUI线程中。 也许这会在Qt 6中改变。

除上述内容外,还应记住,由于Qt可在许多不同的平台上工作(包括不支持多线程的平台),因此该框架提供了相当数量的回退,并且在某些情况下,渲染线程功能实际上是由同一gui线程执行的。 在这种情况下,整个UI(包括渲染)都位于一个线程中,并且同步问题会自动消失。 这种情况与较旧的基于Qt4样式的基于窗口小部件的UI相似。 如果愿意,可以通过将环境变量QSG_RENDER_LOOP设置为适当的选项来使Qt在这种“单线程”模式下工作。

结论


Qt是一个庞大而复杂的框架,在其中使用线程反映了这种复杂性的一部分。但是它的设计非常仔细,合理且能胜任,因此,当您了解Qt中流程的几个关键思想时,操作非常容易而不会出错。

让我再次提醒您要点;

  • 每个对象都有一个拥有它的线程,该对象执行与该对象一起发生的所有事件的处理程序,包括处理排队的信号
  • 如果线程“拥有”该对象不执行Qt事件循环,则属于该对象的对象将不会接收任何消息,并且线程本身也不会响应告诉它退出的尝试()
  • 父母和后代始终生活在同一流中。只能将顶级父级从流转移到流。违反此规则可能会导致setParent或moveToThread操作的静默失败
  • 未指定其父对象的对象将成为该对象创建的线程的属性。
  • 除渲染后端外,所有GUI对象都必须存在于GUI流中
  • GUI线程是在其中创建QApplication对象的线程

我希望这将帮助您更有效地使用Qt,并且不会犯与其多线程模型相关的错误。

Source: https://habr.com/ru/post/zh-CN467261/


All Articles