Threading Basics

什么是线程?

线程是关于并行做事的, 就像进程一样. 那么线程与进程有何不同呢? 当你在电子表格上计算时, 同一桌面上可能还会运行一个媒体播放器, 播放你最喜欢的歌曲. 下面是两个进程并行工作的示例: 一个运行电子表格程序; 另一个运行媒体播放器. 多任务处理是一个众所周知的术语. 仔细观察媒体播放器就会发现, 在一个进程中又出现了并行发生的事情. 当媒体播放器向音频驱动程序发送音乐时, 用户界面及其所有功能都会不断更新. 这就是线程的用途 -- 单个进程内的并发.

那么并发是如何实现的呢? 单核 CPU 上的并行工作是一种幻觉, 有点类似于电影中移动图像的幻觉. 对于进程来说, 这种错觉是通过在很短的时间内中断处理器在一个进程上的工作而产生的. 然后处理器继续进行下一个过程. 为了在进程之间切换, 当前的程序计数器被保存并加载下一个处理器的程序计数器. 这还不够, 因为还需要对寄存器以及某些架构和操作系统特定数据进行同样的操作.

正如一个 CPU 可以为两个或多个进程提供动力一样, 也可以让 CPU 在一个进程的两个不同代码段上运行. 当一个进程启动时, 它总是执行一个代码段, 因此该进程被称为有一个线程. 然而, 程序可能决定启动第二个线程. 然后, 在一个进程内同时处理两个不同的代码序列. 单核 CPU 上的并发性是通过重复保存程序计数器和寄存器, 然后加载下一个线程的程序计数器和寄存器来实现的. 在活动线程之间循环不需要程序的配合. 当切换到下一个线程时, 一个线程可能处于任何状态.

当前CPU设计的趋势是拥有多个核心. 典型的单线程应用程序只能使用一个内核. 然而, 具有多个线程的程序可以分配给多个核心, 从而使事情以真正并发的方式发生. 因此, 将工作分配给多个线程可以使程序在多核 CPU 上运行得更快, 因为可以使用额外的内核.

GUI 线程和工作线程

如前所述, 每个程序在启动时都有一个线程. 该线程称为 "主线程" (在 Qt 应用程序中也称为 "GUI 线程" ). Qt GUI 必须在此线程中运行. 所有 widget 和几个相关类 (如, QPixmap) 都不能在辅助线程中工作. 辅助线程通常称为 "工作线程" 因为它用于从主线程分离处理工作.

Simultaneous Access to Data

每个线程都有自己的堆栈, 这意味着每个线程都有自己的调用历史记录和局部变量. 与进程不同, 线程共享相同的地址空间. 下图显示了线程的构建块如何位于内存中. 程序计数器和不活动线程的寄存器通常保存在内核空间中. 每个线程都有一个共享的代码副本和一个单独的堆栈.

"Thread visualization"

如果两个线程具有指向同一对象的指针, 则两个线程可能会同时访问该对象, 这可能会破坏该对象的完整性. 很容易想象当同一对象的两个方法同时执行时可能会出现很多问题.

有时需要从不同的线程访问一个对象; 例如, 当生活在不同线程中的对象需要通信时. 由于线程使用相同的地址空间, 因此线程交换数据比进程更容易, 更快. 数据不必序列化和复制. 传递指针是可能的, 但必须严格协调哪个线程接触哪个对象. 必须防止对一个对象同时执行操作. 有多种方法可以实现此目的, 下面介绍其中一些方法.

那么怎样做才能安全呢? 线程中创建的所有对象都可以在该线程中安全地使用, 前提是其他线程没有对它们的引用, 并且对象没有与其他线程的隐式耦合. 当数据在实例之间共享时, 例如静态成员, 单例或全局数据, 可能会发生这种隐式耦合. 熟悉 线程安全和可重入 类和函数的概念.

Using Threads

线程基本上有两种用途:

  • 使用多核处理器提高处理速度.
  • 将持久处理工作或阻塞函数放入其他线程, 保持 GUI 线程或其他关键线程的响应能力.

何时使用线程的替代方案

开发人员需要非常小心线程. 启动其他线程很容易, 但很难确保所有共享数据保持一致. 问题通常很难发现, 因为它们可能只偶尔出现一次或仅在特定的硬件配置上出现. 在创建线程来解决某些问题之前, 应考虑可能的替代方案.

AlternativeComment
QEventLoop::processEvents()在耗时的计算过程中重复调用 QEventLoop::processEvents() 可防止 GUI 阻塞. 但是, 此解决方案无法很好地扩展, 因为对 processEvents() 的调用可能发生得太频繁, 或者不够频繁, 具体取决于硬件.
QTimer有时可以使用定时器方便地完成后台处理, 以安排在未来某个时间点执行槽函数. 一旦没有更多事件要处理, 间隔为 0 的定时器就会超时.
QSocketNotifier QNetworkAccessManager QIODevice::readyRead()这是一个或多个线程(线程在慢速网络连接上阻塞读取)的替代方案. 只要响应一大块网络数据的计算能够快速执行, 这种反应式设计比线程中的同步等待要好. 与线程相比, 反应式设计不易出错且节能. 在许多情况下, 还具有性能优势.

一般来说, 建议仅使用安全且经过测试的路径, 并避免引入临时线程概念. QtConcurrent 模块提供了一个便捷接口, 将工作分配给所有处理器的内核. 线程代码完全隐藏在 QtConcurrent 框架中, 因此你不必关心细节. 但是, 当需要与正在运行的线程通信时, 不能使用QtConcurrent, 并且不应该使用它来处理阻塞操作.

你应该如何选择 Qt 线程技术?

参见 Multithreading Technologies in Qt.

Qt Thread Basics

下面的章节描述了 QObjects 如何与线程交互, 程序如何安全地从多个线程访问数据以及异步执行如何在不阻塞线程的情况下产生结果.

QObject and Threads

如上所述, 开发人员在从其他线程调用对象的方法时必须始终小心. 线程亲和性 并不能改变这种情况. Qt 文档将多种方法标记为线程安全的. postEvent() 是一个值得注意的例子. 线程安全方法可以同时从不同线程调用.

在通常没有并发访问方法的情况下, 在发生并发访问之前调用其他线程中对象的非线程安全方法可能会工作数千次, 从而导致意外行为. 编写测试代码并不能完全保证线程的正确性, 但仍然很重要. 在 Linux 上, Valgrind 和 Helgrind 可以帮助检测线程错误.

Protecting the Integrity of Data

编写多线程应用程序时, 必须格外小心, 以避免数据损坏. 有关如何安全使用线程的讨论, 参见 Synchronizing Threads.

Dealing with Asynchronous Execution

获取工作线程结果的一种方法是等待线程终止. 然而, 在许多情况下, 阻塞等待是不可接受的. 阻塞等待的替代方案是使用已发布事件或排队信号和槽进行异步结果传递. 这会产生一定的开销, 因为操作的结果不会出现在下一个源代码行上, 而是出现在源文件中其他位置的槽函数中. Qt 开发人员习惯于使用这种异步行为,因为它与 GUI 应用程序中使用的事件驱动编程非常相似.

示例

Qt 附带了几个使用线程的示例. 简单示例参见 QThreadQThreadPool. 更高级的信息参见 Threading and Concurrent Programming Examples.

Digging Deeper

线程是一个非常复杂的主题. Qt 提供的线程类比我们在本教程中介绍的类还要多. 以下材料可以帮助您更深入地了解该主题: