事件循环与线程 一

本文深入探讨了Qt中的事件循环机制,解析了事件循环在Qt应用中的作用及其与线程之间的关系。介绍了事件循环的基本概念、如何避免阻塞事件循环以及如何在必要时强制执行事件循环。

 

初次读到这篇文章,译者感觉如沐春风,深刻体会到原文作者是花了很大功夫来写这篇文章的,文章深入浅出,相信仔细读完原文或下面译文的读者一定会有收获。

由于原文很长,原文作者的行文思路是从事件循环逐渐延伸到线程使用的讨论,译者因时间受限,暂发表有关事件循环的译文。另一半线程实用的译文将近期公布。文中有翻译不当的地方,还请见谅。

 

介绍

线程是qt channel里最流行的讨论话题之一。许多人加入了讨论并询问如何解决他们在运行跨线程编程时所遇到的问题。

快速检阅一下他们的代码,在发现的问题当中,十之八九遇到得最大问题是他们在某个地方使用了线程,而随后又坠入了并行编程的陷阱。Qt中创建、运行线程的“易用”性、缺乏相关编程尤其是异步网络编程知识或是养成的使用其它工具集的习惯、这些因素和Qt的信号槽架构混合在一起,便经常使得人们自己把自己射倒在了脚下。此外,Qt对线程的支持是把双刃剑:它即使得你在进行Qt多线程编程时感觉十分简单,但同时你又必须对Qt所新添加许多的特性尤为小心,特别是与QObject的交互。

本文的目的不是教你如何使用线程、如何适当地加锁,也不是教你如何进行并行开发或是如何写可扩展的程序;关于这些话题,有很多好书,比如这个链接给的推荐读物清单.  这篇文章主要是为了向读者介绍Qt 4的事件循环以及线程使用,其目的在于帮助读者们开发出拥有更好结构的、更加健壮的多线程代码,并回避Qt事件循环以及线程使用的常见错误。

先决条件

考虑到本文并不是一个线程编程的泛泛介绍,我们希望你有如下相关知识:

  • C++基础;
  • Qt 基础:QOjbects , 信号/槽,事件处理;
  • 了解什么是线程、线程与进程间的关系和操作系统;
  • 了解主流操作系统如何启动、停止、等待并结束一个线程;
  • 了解如何使用mutexes, semaphores 和以及wait conditions 来创建一个线程安全/可重入的函数、数据结构、类。

本文我们将沿用如下的名词解释,即

  • 可重入 一个类被称为是可重入的:只要在同一时刻至多只有一个线程访问同一个实例,那么我们说多个线程可以安全地使用各自线程内自己的实例。 一个函数被称为是可重入的:如果每一次函数的调用只访问其独有的数据(译者注:全局变量就不是独有的,而是共享的),那么我们说多个线程可以安全地调用这个函数。 也就是说,类和函数的使用者必须通过一些外部的加锁机制来实现访问对象实例或共享数据的序列化。
  • 线程安全  如果多个线程可以同时使用一个类的对象,那么这个类被称为是线程安全的;如果多个线程可以同时使用一个函数体里的共享数据,那么这个函数被称为线程安全的。

(译者注:   更多可重入(reentrant)和t线程安全(thread-safe)的解释:  对于类,如果它的所有成员函数都可以被不同线程同时调用而不相互影响——即使这些调用是针对同一个类对象,那么该类被定义为线程安全。 对于类,如果其不同实例可以在不同线程中被同时使用而不相互影响,那么该类被定义为可重入。在Qt的定义中,在类这个层次,thread-safe是比reentrant更严格的要求)

事件与事件循环

Qt作为一个事件驱动的工具集,其事件和事件派发起到了核心的作用。本文将不会全面的讨论这个话题,而是会聚焦于与线程相关的一些关键概念。想要了解更多的Qt事件系统专题参见 (这里[doc.qt.nokia.com]这里 [doc.qt.nokia.com] ) (译者注:也欢迎参阅译者写的博文:浅议Qt的事件处理机制一

一个Qt的事件是代表了某件另人感兴趣并已经发生的对象;事件与信号的主要区别在于,事件是针对于与我们应用中一个具体目标对象(而这个对象决定了我们如何处理这个事件),而信号发射则是“漫无目的”。从代码的角度来说,所有的事件实例是QEvent [doc.qt.nokia.com]的子类,并且所有的QObject的派生类可以重载虚函数QObject::event(),从而实现对目标对象实例事件的处理。

事件可以产生于应用程序的内部,也可以来源于外部;比如:

  • QKeyEvent和QMouseEvent对象代表了与键盘、鼠标相关的交互事件,它们来自于视窗管理程序。
  • 当计时器开始计时,QTimerEvent 对象被发送到QObject对象中,它们往往来自于操作系统。
  • 当一个子类对象被添加或删除时,QChildEvent对象会被发送到一个QObject对象重,而它们来自于你的应用程序内部

对于事件来讲,一个重要的事情在于它们并没有在事件产生时被立即派发,而是列入到一个事件队列Event queue)中,等待以后的某一个时刻发送。分配器(dispatcher )会遍历事件队列,并且将入栈的事件发送到它们的目标对象当中,因此它们被称为事件循环(Event loop). 从概念上讲,下段代码描述了一个事件循环的轮廓:

 

 

我们是通过运行QCoreApplication::exec()来进入Qt的主体事件循环的;这会引发阻塞,直至QCoreApplication::exit() 或者 QCoreApplication::quit() 被调用,进而结束循环。

这个“wait_for_more_events()” 函数产生阻塞,直至某个事件的产生。 如果我们仔细想想,会发现所有在那个时间点产生事件的实体必定是来自于外部的资源(因为当前所有内部事件派发已经结束,事件队列里也没有悬而未决的事件等待处理),因此事件循环被这样唤醒:

  • 视窗管理活动(键盘按键、鼠标点击,与视窗的交互等等);
  • socket活动 (有可见的用来读取的数据或者一个可写的非阻塞Socket, 一个新的Socket连接的产生);
  • timers (即计时器开始计时)
  • 其它线程Post的事件(见后文)。

Unix系统中,视窗管理活动(即X11)通过Socket(Unix 域或者TCP/IP)通知应用程序(事件的产生),因为客户端使用它们与X服务器进行通讯。 如果我们决定用一个内部的socketpair(2)来实现跨线程的事件派发,那么视窗管理活动需要唤醒的是

  • sockets;
  • timers;

这也是select(2) 系统调用所做的: 它为视窗管理活动监控了一组描述符,如果一段时间内没有任何活动,它会超时。Qt所要做的是把系统调用select的返回值转换为正确的QEvent子类对象,并将其列入事件队列的栈中,现在你知道事件循环里面装着什么东西了吧:)

为什么需要运行事件循环?

下面的清单并不全,但你会有一幅全景图,你应该能够猜到哪些类需要使用事件循环。

  • Widgets 绘图与交互: 当派发QPaintEvent事件时,QWidget::paintEvent() 将会被调用。QPaintEvent可以产生于内部的QWidget::update() ,也可以产生于外部的视窗管理(比如,一个显示被隐藏的窗口)。同样的,各种各样的交互(键盘、鼠标等)所对应的事件均需要事件循环来派发。
  • Timers: 长话短说,当select(2)或相类似的调用超时时,计时器开始计时,因此需要让Qt通过返回事件循环让那些调用为你工作。
  • Networking: 所以底层的Qt网络类(QTcpSocket, QUdpSocket, QTcpServer等)均被设计成异步的。当你调用read()时,它们仅仅是返回已经可见的数据而已; 当你调用write()时,它们仅是将写操作列入执行计划表待稍后执行。 真正的读写仅发生于事件循环返回的时候。 请注意虽然Qt网络类提供了相应的同步方法(waitFor* 一族),但它们是不被推荐使用的,原因在于他们阻塞了正在等待的事件循环。向QNetworkAccessManager这样的上层类,并不提供同步API 而且需要事件循环。

阻塞事件循环

在讨论为什么你永远都不要阻塞事件循环之前,让我们尝试着再进一步弄明白到底“阻塞”意味着什么。假定你有一个按钮widget,它被按下时会emit一个信号;还有一个我们下面定义的Worker对象连接了这个信号,而且这个对象的槽做了很多耗时的事情。当你点击完这个按钮后,从上之下的函数调用栈如下所示:

 

 

 

在main()中,我们通过调用QApplication::exec() (如上段代码第2行所示)开启了事件循环。视窗管理者发送了鼠标点击事件,该事件被Qt内核捕获,并转换成QMouseEvent ,随后通过QApplication::notify() (notify并没有在上述代码里显示)发送到我们的widget的event()方法中(第4行)。因为Button并没有重载event(),它的基类QWidget方法得以调用。 QWidget::event() 检测出传入的事件是一个鼠标点击,并调用其专有的事件处理器,即Button::mousePressEvent() (第5行)。我们重载了 mousePressEvent方法,并发射了Button::clicked()信号(第6行),该信号激活了我们worker对象中十分耗时的Worker::doWork()槽(第8行)。(译者注:如果你对这一段所描述得函数栈的更多细节,请参见浅议Qt的事件处理机制一

当worker对象在繁忙的工作时,事件循环在做什么呢? 你也许猜到了答案:什么也没做!它分发了鼠标点击事件,并且因等待event handler返回而被阻塞。我们阻塞了事件循环,也就是说,在我们的doWork()槽(第8行)干完活之前再不会有事件被派发了,也再不会有pending的事件被处理。

当事件派发被就此卡住时,widgets 也将不会再刷新自己(QPaintEvent对象将在事件队列里静候),也不能有进一步地与widgets交互的事件发生,计时器也不会在开始计时,网络通讯也将变得迟钝、停滞。更严重的是,许多视窗管理程序会检测到你的应用不再处理事件,从而告诉用户你的程序不再有响应(not responding). 这就是为什么快速的响应事件并尽可能快的返回事件循环如此重要的原因

强制事件循环

那么,对于需要长时间运行的任务,我们应该怎么做才会不阻塞事件循环? 一个可行的答案是将这个任务移动另一个线程中:在一节,我们会看到如果去做。一个可能的方案是,在我们的受阻塞的任务中,通过调用QCoreApplication::processEvents() 人工地强迫事件循环运行。QCoreApplication::processEvents() 将处理所有事件队列中的事件并返回给调用者。

另一个可选的强制地重入事件的方案是使用QEventLoop [doc.qt.nokia.com] 类,通过调用QEventLoop::exec() ,我们重入了事件循环,而且我们可以把信号连接到QEventLoop::quit() 槽上使得事件循环退出,如下代码所示:

 

QNetworkReply 没有提供一个阻塞式的API,而且它要求运行一个事件循环。我们进入到一个局部QEventLoop,并且当回应完成时,局部的事件循环退出。

当重入事件循环是从“其他路径”完成的则要非常小心:它可能会导致无尽的递归循环!让我们回到Button这个例子。如果我们再在doWork() 槽里面调用QCoreApplication::processEvents() ,这时用户又一次点击了button,那么doWork()槽将会再次被调用:

 

 

 

一个快速并且简单的临时解决办法是把QEventLoop::ExcludeUserInputEvents 传递给QCoreApplication::processEvents(), 也就是说,告诉事件循环不要派发任何用户输入事件(事件将简单的呆在队列中)。

同样地,使用一个对象的deleteLater() 来实现异步的删除事件(或者,可能引发某种“关闭(shutdown)”的任何事件)则要警惕事件循环的影响。 (译者注:deleteLater()将在事件循环中删除对象并返回)

 

 

可以看到,我们并没有用QCoreApplication::processEvents()  (从Qt 4.3之后,删除事件不再被派发 ),但是我们确实用到了其他的局部事件循环(像我们QEventLoop 启动的这个循环,或者下面将要介绍的QDialog::exec())。

切记当我们调用QDialog::exec()或者 QMenu::exec()时,Qt进入了一个局部事件循环。Qt 4.5 以后的版本,QDialog 提供了QDialog::open() 方法用来再不进入局部循环的前提下显示window-modal式的对话框

 

 

至此事件循环(event loop)的讨论告一段落,接下来,我们要讨论Qt的多线程:事件循环与线程二

 

请尊重原创作品和译文。转载请保持文章完整性,并以超链接形式注明原始作者主站点地址,方便其他朋友提问和指正。 

 

<think>我们正在讨论事件循环线程、协程之间关系。根据之前的引用,特别是引用[1]中提到的结构,我们可以这样理解: 事件循环(Event Loop)是协程的运行环境,它负责管理协程的调度和执行。而线程是操作系统调度的最小单位,线程内可以运行事件循环,从而管理多个协程。 引用[1]中提到: ``` 进程 ├── 线程1 │ └── (事件循环1) │ ├── 协程a │ ├── 协程b │ └── 协程c ├── 线程2 │ └── (事件循环2) │ ├── 协程d │ └── 协程e └── 线程3 └── (可能没有事件循环,只跑普通任务) ``` 这说明: 1. 个进程可以包含多个线程。 2. 每个线程可以有自己的事件循环(但不是必须的,如线程3)。 3. 事件循环可以管理多个协程(如事件循环1管理协程a、b、c)。 引用[2]提到:事件循环是协程的运行环境,它负责管理协程的生命周期,包括协程的创建、调度和销毁。事件循环个单线程调度器,在单线程中轮询任务队列,决定何时执行哪个任务。 引用[4]详细描述了事件循环的工作原理:它是个无限循环,不断检查任务队列,执行就绪任务,并在任务等待时切换到其他任务。 引用[5]则对比了协程和线程:协程和线程都是实现并发编程的技术,但协程由事件循环在单线程内调度,而线程由操作系统调度。 因此,我们可以这样总结: ### 事件循环线程、协程关系详解 #### 1. **层级关系** - **进程(Process)**:操作系统资源分配的基本单位,至少包含线程。 - **线程(Thread)**:操作系统调度的基本单位,线程可以包含事件循环。 - **事件循环(Event Loop)**:协程的调度器,运行在线程内部,负责管理多个协程的执行顺序。 - **协程(Coroutine)**:轻量级的用户态线程,由事件循环调度,在同线程内并发执行。 用数学表达层级关系: $$ \text{进程} \supset \text{线程} \supset \text{事件循环} \supset \text{协程} $$ #### 2. **事件循环线程** - **绑定**:线程最多拥有个运行中的事件循环[^1]。 - **独立性**:不同线程事件循环彼此独立,互不干扰。 - **非强制**:线程可以不使用事件循环(如运行同步代码的线程)。 #### 3. **事件循环协程** - **调度机制**:事件循环通过任务队列管理协程: 1. 协程通过 `async def` 定义,调用时返回协程对象。 2. 使用 `create_task()` 或 `ensure_future()` 将协程对象注册到事件循环的任务队列。 3. 事件循环按以下规则调度: - 执行 `await` 时暂停当前协程,将控制权交还事件循环。 - 事件循环选择下个就绪的协程执行。 - 当I/O完成或定时器触发时,关联的协程被唤醒[^4]。 - **并发模型**:单线程内通过协程切换实现高并发。工作原理可抽象为: ```mermaid graph LR A[事件循环启动] --> B{任务队列空?} B -- 是 --> C[等待I/O事件] B -- 否 --> D[取出任务执行] D --> E[遇到await?] E -- 是 --> F[挂起任务并注册I/O回调] E -- 否 --> G[执行直到完成] F --> C C --> H[I/O完成触发回调] H --> I[任务重新入队] I --> B ``` #### 4. **线程协程的对比** | **特性** | **线程** | **协程** | |------------------|--------------------------|--------------------------| | **调度方** | 操作系统内核 | 用户态事件循环 | | **切换开销** | 高(需内核介入) | 极低(无系统调用) | | **并发量** | 百~千级别 | 万~百万级别 | | **阻塞影响** | 阻塞整个线程 | 仅阻塞当前协程 | | **适用场景** | CPU密集型、跨核并行 | I/O密集型、高并发网络请求 | #### 5. **协作模式** - **多线程+协程**:常见于高性能服务器,例如: - 主线程运行事件循环处理网络I/O。 - 工作线程池通过 `run_in_executor()` 执行CPU密集型任务[^2]。 - **跨线程调度**:协程通常只能在创建它的线程中运行,但可通过 `call_soon_threadsafe()` 实现跨线程任务提交。 #### 6. **使用注意事项** 1. **协程非线程安全**:同事件循环内的协程共享线程资源,需用锁(`asyncio.Lock`)避免竞态条件。 2. **阻塞操作分离**:耗时同步操作应放入线程池,防止阻塞事件循环[^2]: ```python # 正确示例:将同步函数转入线程池执行 await loop.run_in_executor(None, time.sleep, 5) ``` 3. **线程局部存储**:协程无法直接使用线程局部变量(`threading.local`),需改用 `contextvars`。 > 💡 **关键结论** > 事件循环是**协程的容器**,线程是**事件循环的容器**。三者形成 `线程事件循环 → 协程` 的嵌套关系,共同支撑高并发异步编程模型。 §§相关问题§§ 1. 如何在多线程中创建和运行独立的事件循环? 2. 事件循环的调度算法是如何实现优先级的? 3. 协程切换相比线程切换具体节省了多少开销? 4. 为什么不允许在已运行事件循环线程中再次启动事件循环?[^1] [^1]: 事件循环线程通常是绑定的关系 [^2]: 协程中应避免同步阻塞操作 [^4]: 事件循环的核心是任务队列和I/O事件监听 </think>### 事件循环线程协程的关系详解 根据引用内容,三者关系可总结为以下核心模型(引用[1]): ```mermaid graph TD A[进程] --> B[线程1] A --> C[线程2] A --> D[线程3] B --> E[事件循环1] E --> F[协程a] E --> G[协程b] E --> H[协程c] C --> I[事件循环2] I --> J[协程d] I --> K[协程e] D --> L[同步任务] # 无事件循环 ``` #### 1. **层级关系** - **进程**:操作系统资源分配的基本单位 - **线程**:CPU调度的基本单位,个进程可包含多个线程 - **事件循环**:线程内部的**协程调度器**(非必需组件) - **协程**:在事件循环中运行的**轻量级执行单元** 数学表达: $$\text{线程} \supset \text{事件循环} \supset \text{协程}$$ > 关键点:线程最多拥有**个运行中**的事件循环(引用[1][^1]),但线程可以不包含事件循环(如线程3) #### 2. **事件循环的核心作用** 作为协程的调度引擎(引用[2][^2][^4]): 1. **任务队列管理**:维护待执行的协程队列 $$ \text{任务队列} = \{ \text{协程}_1, \text{协程}_2, ..., \text{协程}_n \} $$ 2. **事件监听**:监控I/O操作、定时器等异步事件 3. **执行调度**: ```mermaid graph LR A[启动循环] --> B{有就绪协程?} B -->|是| C[执行协程] B -->|否| D[等待事件] C --> E{遇到await?} E -->|是| F[挂起协程] E -->|否| G[继续执行] F --> H[注册回调] H --> D D --> I[事件触发] I --> J[唤醒关联协程] J --> B ``` 4. **上下文切换**:通过`await`实现**用户态切换**(万级并发),而非线程的**内核态切换**(千级并发)(引用[5][^5]) #### 3. **线程事件循环的绑定关系 | **线程类型** | **事件循环** | **任务特点** | **典型场景** | |--------------------|--------------|--------------------------|---------------------| | 异步线程 | 有 | 协程并发执行 | Web服务器请求处理 | | 同步线程 | 无 | 阻塞式执行 | 计算密集型任务 | | 混合线程(罕见) | 有+无 | 协程同步代码交替执行 | 需兼容旧代码的系统 | > ⚠️ 重要限制:**同线程内协程不能包含阻塞操作**(引用[2][^2]) > 错误示例:`async def foo(): time.sleep(5)` > 正确方案:`await asyncio.sleep(5)` 或用 `run_in_executor()` #### 4. **协程的运行本质 在事件循环调度下,协程通过状态机实现并发: ```python async def sample_coroutine(): print("Start") # 状态1 await asyncio.sleep(1) # → 挂起 → 事件循环执行其他协程 print("End") # 状态2(1秒后由事件循环唤醒) ``` #### 5. **三者的性能对比** | **维度** | **线程** | **协程+事件循环** | **优势比** | |---------------|-------------------|----------------------|--------------| | 切换开销 | 微秒级(内核参) | 纳秒级(用户态切换) | $10^3$倍 | | 内存占用 | MB级(默认栈) | KB级(动态栈) | $10^3$倍 | | 并发量 | 千级 | 百万级 | $10^3$倍 | | 适用场景 | CPU密集型 | I/O密集型 | 互补 | #### 6. **典型协作模式 ```python # 多线程+多事件循环架构(引用[1]) import threading import asyncio async def coro_task(id): # 协程任务 print(f"Coroutine {id} starts") await asyncio.sleep(1) print(f"Coroutine {id} ends") def thread_entry(): # 线程入口 loop = asyncio.new_event_loop() asyncio.set_event_loop(loop) loop.run_until_complete(asyncio.gather( coro_task(1), coro_task(2) )) # 启动两个含事件循环线程 threading.Thread(target=thread_entry).start() threading.Thread(target=thread_entry).start() ``` > 💡 **核心价值**:事件循环让**单线程内协程并发**成为可能,通过`await`交出控制权实现**非阻塞并发**,最终在少量线程上实现高并发I/O处理(引用[3][^4])
评论 3
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值