Files
scuc-qt-course/docs/teaching/MULTITHREADING.md
T
张宗平 993aa30a1c Mandelbrot 教学示例增强:主线程渲染循环至 ≥1.5s,让界面冻结肉眼可感
实测单帧 820×640@256 单线程仅约 70ms,远低于人眼可感阈值,直接算一帧看不出
「冻结」。故「主线程渲染」按钮改为自适应循环渲染直到累计 ≥ 1500ms(实测 ~23 帧 /
1506ms),上限 100 轮防止极慢机器卡死过久。冻结的本质是事件循环被占用,循环只是
模拟任意一个占着 GUI 线程不返回的耗时操作。

- mandelbrot_widget.cpp:onRenderOnMainThread 改 do-while 循环,标签加「· N 轮」
- README.md:补冻结时长说明与课堂演示技巧
- 设计文档同步:三段式对比章节更新实测数据与冻结本质解释
- 新增 docs/teaching/MULTITHREADING.md:多线程教学要点汇总

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-09 16:50:46 +08:00

367 lines
19 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Qt 多线程编程 —— 一课时基准讲义
> **适用对象**:已掌握 C++ 基础语法与 Qt 信号槽机制的中初级学员。
> **讲解时长**:约 60 分钟(含看代码与提问),各节标题后标注的建议时长可作节奏参考。
> **配套环境**Qt 5(本文示例基于 Qt 5.14 验证;worker-object 等核心模式在 Qt 6 完全一致)。
> **核心目标**:学完后能回答三个问题——① Qt 为什么需要多线程;② 槽函数到底在哪个线程执行;③ 给你一段耗时逻辑,你能用最稳妥的方式把它移出主线程而不崩溃。
---
## 引言:为什么要多线程(5′)
绝大多数 GUI 程序都只有**一个主线程**(也叫 GUI 线程)。Qt 的主线程一直在跑一个
**事件循环**`QApplication::exec()`)——它不停地从事件队列里取事件并处理:鼠标点击、
键盘按键、定时器到期、重绘请求、网络数据到达……
**关键事实**:事件循环是**串行**的。处理一个事件时,后面的事件只能排队等。
那么,当主线程在执行一个**耗时操作**(比如解析一个大文件、做一次复杂计算、等待网络响应)时,
会发生什么?——它在忙,事件循环停摆,所有排队的事件(包括"请重绘界面""请响应鼠标"
全部被卡住。表现就是:
- 按钮点了没反应;
- 窗口拖不动、缩放不了;
- 进度条不动、界面"未响应"、Windows 标题栏甚至会出现"程序无响应"。
这就是 Qt 多线程存在的**唯一根本理由**:**把耗时操作搬出主线程,让主线程专心响应界面。**
> 一句话心法:**多线程在 Qt 里的首要目的不是"更快",而是"界面不卡"。** 当然,顺带还能用
> 多核并行加速,但那是第二位的。记住这个优先级,后面所有设计决策都有了依据。
---
## 一、基本概念(12′)
### 1.1 线程与进程
一句话带过:**进程是资源分配单位,线程是 CPU 调度单位**。一个进程至少有一个线程(主线程),
可以再开若干子线程;同进程的线程**共享同一片内存空间**(这点对后面的"竞态/加锁"至关重要)。
### 1.2 主线程与事件循环
如引言所述,主线程跑 `exec()` 事件循环。**Qt 里只有主线程能创建和操作 GUI 控件**
(QWidget 及其子类)——这是铁律,记住它,"最佳实践"里会反复用到。
### 1.3 线程亲和性(thread affinity)—— 本课最重要的概念
每个 `QObject` 都有一个"**归属线程**",即它的**线程亲和性**。可以用 `obj->thread()` 查询。
亲和性的核心规则:**一个 QObject 的槽函数,默认在它「自己归属的线程」里执行,而不是在
调用它的线程里执行。** 这句话是理解 Qt 多线程的钥匙。
默认情况下,一个对象在哪条线程被 `new` 出来,它的亲和性就是哪条线程。要改变亲和性,
`moveToThread(thread)`——这一步之后,该对象的槽函数就会在 `thread` 里执行。
> 把亲和性理解成"这个对象的家"。信号槽机制会自动把"去这个对象家敲门"(调用它的槽)
> 这件事,调度到它家所在的线程去做——这就是下一小节"排队连接"的本质。
### 1.4 跨线程信号槽:三种连接类型
`QObject::connect` 在建立连接时,会根据**发送者和接收者是否在同一线程**,自动选择连接类型:
| 连接类型 | 触发方式 | 何时被 Qt 自动选用 |
|---|---|---|
| **DirectConnection**(直接) | `emit` 处直接调用槽,**同步**,在发送者线程执行 | 发送者与接收者**在同一线程** |
| **QueuedConnection**(排队) | `emit` 把调用**投递**到接收者线程的事件队列,**异步**,在接收者线程执行 | 发送者与接收者**在不同线程** |
| **AutoConnection**(自动,默认) | 运行时判断二者是否同线程,同则直接、不同则排队 | 不指定类型时的默认值 |
**排队连接是 Qt 多线程的魔法所在**:你在子线程里 `emit resultReady(x)`Qt 会自动把
"调用主线程那个槽"打包成一个事件,投递到主线程事件队列。主线程在下一轮事件循环里取出它、
执行槽。于是——**子线程算出的结果,自动、安全地"回到"了主线程,全程不需要你手动加锁。**
这也是为什么官方推荐"用信号槽在跨线程间传递数据,而不是共享变量 + 锁"。
---
## 二、使用线程的方法(15′)
Qt 提供了几条不同的多线程路径,从底层到高层依次为:
### 方法一:继承 `QThread`,重写 `run()`
最古老、教程里最常见的写法。把耗时逻辑写进 `run()``start()``run()` 在子线程执行。
```cpp
class MyThread : public QThread {
protected:
void run() override {
// 这段代码在子线程执行
// ... 耗时操作 ...
}
};
```
**能跑,但容易踩坑**:很多人以为"MyThread 的所有槽都在子线程执行"——**错**。`QThread`
对象本身(包括它的槽)依然活在**创建它的线程**(通常是主线程)。只有 `run()` 里的代码
在子线程。这个认知错位会导致"明明 moveTo 了,槽却还在主线程跑"的困惑。
### 方法二:Worker-Object + `moveToThread`(官方推荐)⭐
不继承 `QThread`,而是把耗时逻辑写成一个**普通 `QObject` 子类**Worker)的槽函数,
再用 `moveToThread()` 把它整个挪到子线程。此后 Worker 的槽就在子线程执行,
通过信号槽与主线程双向通信。
这是 Qt 官方**强烈推荐**的写法,第三节会给完整示例。优点:槽确实在子线程、用信号槽通信
天然线程安全、代码清晰。
### 方法三:`QRunnable` + `QThreadPool`(任务并行)
适合"**一堆独立的、可并行的小任务**"(如分块计算、批量处理)。每个任务继承 `QRunnable`
`run()` 写计算逻辑,丢进 `QThreadPool` 由它调度到若干线程并行执行。
与方法二相比:worker-object 适合"**一个长期存在的后台对象**",`QRunnable` 适合
"**大量一次性任务**"。第三节也给出示例。
### 方法四(了解即可):`QtConcurrent` + `QFuture`
更高层、更省代码的 API。`QtConcurrent::run()` 一行就能把一个函数扔到线程池;
`QFuture` 用来取结果/取消/等待。**适合简单的一次性并行**,缺点是控制粒度粗、调试不如
QThread 直观。本课不展开,知道"它是更上层的封装"即可。
### 决策表:何时用哪种?
| 场景 | 推荐方法 |
|---|---|
| 一个长期运行的后台服务(持续监听、定期处理) | **方法二**worker-object |
| 大量独立、可并行的一次性任务(批量、分块) | **方法三**QRunnable + 线程池) |
| 简单地把单个函数异步跑一次,不想写类 | **方法四**QtConcurrent::run |
| 维护老代码、或确需完全控制线程入口 | 方法一(继承 QThread,慎用) |
> 给中初级学员的默认建议:**绝大多数情况用方法二(worker-object)。** 它最不易出错、最契合
> Qt 的信号槽体系。除非任务特征明确指向方法三/四,否则一律用方法二起步。
---
## 三、如何使用多线程(18′)
### 3.1 完整示例:worker-object 模式(主线示例,务必看懂)
下面是一个**自包含、可编译、可运行**的控制台程序。它把"1 加到 5000 万"这件耗时计算
丢到子线程,算完把结果安全地传回主线程打印。
```cpp
// main.cpp —— worker-object 多线程最小完整示例(控制台,无需 GUI)
#include <QCoreApplication>
#include <QObject>
#include <QThread>
#include <QDebug>
// 工作对象:普通 QObject 子类。注意它「不」继承 QThread。
class Worker : public QObject {
Q_OBJECT
public slots:
void doWork() { // 这个槽将运行在子线程(因为 worker 被 moveToThread
// 模拟耗时计算(真实场景:解析大文件、网络请求、复杂运算……)
// 用 qint64 防止溢出:5000 万求和 ≈ 1.25e15,远超 int 上限
qint64 sum = 0;
for (int i = 1; i <= 50'000'000; ++i) sum += i;
emit resultReady(sum); // 算完发信号 → 跨线程排队连接,自动带回主线程
emit finished(); // 通知:工作完成
}
signals:
void resultReady(qint64 result); // 把结果交给主线程
void finished(); // 通知主线程:可以结束了
};
int main(int argc, char* argv[]) {
QCoreApplication app(argc, argv);
auto* thread = new QThread; // 线程对象(本身活在主线程,管理一个子线程)
auto* worker = new Worker; // worker 当前还在主线程
worker->moveToThread(thread); // ★ 关键一步:把 worker 挪到子线程
// —— 四条核心接线(全部用函数指针语法,连接类型交由 Qt 自动判定)——
// ① 线程一启动 → 立刻开始干活
QObject::connect(thread, &QThread::started, worker, &Worker::doWork);
// ② 结果回主线程:接收者 &app 在主线程,于是这是「排队连接」,
// 回调 lambda 必然在主线程执行(看下一行的断言验证)
QObject::connect(worker, &Worker::resultReady, &app, [](qint64 result) {
qDebug() << "计算结果:" << result
<< "(回调在"
<< (QThread::currentThread() == qApp->thread() ? "" : "")
<< "线程执行)";
});
// ③ 优雅退出 + 清理(Qt 官方标准四件套,照抄即可)
QObject::connect(worker, &Worker::resultReady, thread, &QThread::quit); // 算完让子线程事件循环退出
QObject::connect(thread, &QThread::finished, worker, &QObject::deleteLater); // 子线程结束 → 删 worker
QObject::connect(thread, &QThread::finished, thread, &QObject::deleteLater); // → 删 thread
QObject::connect(thread, &QThread::finished, &app, &QCoreApplication::quit);// → 退出程序
thread->start(); // 启动子线程事件循环
return app.exec();
}
#include "main.moc" // Worker 定义在 .cpp 内时需要此行(拆成 .h/.cpp 则由 AUTOMOC 自动处理)
```
**走读这个示例(这是本课重点)**
1. **Worker 不继承 QThread**——这是方法二的精髓。耗时逻辑在 `doWork()` 槽里。
2. **`moveToThread` 改变亲和性**:之后 `doWork` 在子线程执行。对比"方法一"的陷阱——
那里只有 `run()` 在子线程,槽不在;这里 `doWork` 这个槽**确确实实在子线程**。
3. **`started → doWork`**:线程的 `exec()` 一跑起来,`started` 信号触发 `doWork`,开始干活。
4. **`resultReady → 主线程 lambda`**:因为接收者在主线程,Qt 自动用**排队连接**,
把"调用 lambda"投递回主线程。打印里的断言会显示"主线程"——亲手验证排队连接真的把回调带回来了。
5. **优雅退出**`resultReady → quit()` 让子线程事件循环退出 → `finished` 触发 →
`deleteLater` 清理 worker 和 thread,最后 `app.quit()` 结束程序。**没有内存泄漏,没有悬空。**
> **如何亲手验证"排队连接"?** 把第 4 条的回调想象成"更新一个 QLabel"。因为回调确定在主线程,
> 所以可以**安全地**操作 GUI 控件——这正是 worker 模式能安全配合 GUI 的根本原因。
### 3.2 完整示例:`QRunnable` + `QThreadPool`(任务并行)
同样是"1 加到 5000 万",这次切成 4 段丢给线程池并行算,4 段结果都回到主线程汇总。
```cpp
// main.cpp —— QRunnable + 线程池:任务并行最小示例
#include <QCoreApplication>
#include <QObject>
#include <QRunnable>
#include <QThreadPool>
#include <QDebug>
// 一个可并行执行的「任务」。QRunnable 本身不发信号,要发结果须同时继承 QObject。
class SumTask : public QObject, public QRunnable {
Q_OBJECT
int from_, to_;
public:
SumTask(int from, int to) : from_(from), to_(to) { setAutoDelete(true); }
void run() override { // run() 在线程池的某个工作线程执行
qint64 sum = 0;
for (int i = from_; i < to_; ++i) sum += i;
emit done(sum); // 跨线程排队连接,把结果带回主线程
}
signals:
void done(qint64 sum);
};
int main(int argc, char* argv[]) {
QCoreApplication app(argc, argv);
const int N = 4; // 切 4 段并行
const int total = 20'000'000;
const int step = total / N;
auto* results = new qint64[N]{}; // 存每段结果
int got = 0; // 已返回的段数
for (int i = 0; i < N; ++i) {
auto* task = new SumTask(i * step, (i + 1) * step);
// 接收者 lambda 在主线程 → done 经排队连接带回主线程,回调串行执行,天然无锁
QObject::connect(task, &SumTask::done, &app, [&, i](qint64 sum) {
results[i] = sum;
if (++got == N) { // 4 段全部返回
qDebug() << "总和:" << results[0] + results[1] + results[2] + results[3];
app.quit();
}
});
QThreadPool::globalInstance()->start(task); // 丢进全局线程池
}
return app.exec();
// 注:results 是堆对象,示例为简洁未 delete;真实程序用智能指针或栈上容器管理
}
#include "main.moc"
```
**两个示例对照,记住要点**
- **示例 3.1**worker-object):一个 Worker 长期住在一个子线程里,适合"后台服务"。
- **示例 3.2**QRunnable):多个一次性任务被线程池调度,适合"批量并行"。
- **两者都靠排队连接把结果安全带回主线程**——这就是贯穿所有 Qt 多线程的统一范式:
**子线程只管算,结果用信号发给主线程,主线程负责一切后续(更新界面/汇总/落盘)。**
---
## 四、最佳实践与常见坑(8′)
每条都对应一个**真实会崩或会卡**的坑,逐条记住可省去项目期大量排查时间。
### ① 绝不在工作线程里访问 GUI 控件
`QWidget` 及其子类**不是线程安全**的,且只能由主线程操作。在工作线程里直接调
`label->setText(...)` 是**未定义行为**——可能崩溃、可能乱码、可能偶发花屏。
**正解**:子线程算出数据后 `emit` 一个信号,由主线程的槽(排队连接)去更新控件。
(回顾示例 3.1 的回调——它确认在主线程,所以那里才是碰 GUI 的合法位置。)
### ② 优先用信号槽传数据,退而求其次才用 `QMutex`
子线程之间、子线程与主线程之间,**优先选择"通过信号槽传递数据的所有权"**(一个线程
算完、发出去、就不再碰它),而不是"多个线程共享一块内存 + 加锁"。
只有当确实**无法避免共享同一块可变数据**时(如多个写线程更新同一个计数器),才用
`QMutex`(互斥锁)/ `QReadWriteLock`。锁是最后的手段,不是首选——它带来死锁、
性能下降、调试困难。
### ③ `moveToThread` 的两个时序要点
- **`worker` 不能有 parent**。`new Worker(parent)` 之后 `moveToThread` 会失败。务必
`new Worker`(无 parent),由 `deleteLater` 负责它的生命周期。
- **先 `connect`,再 `start`**。确保线程一启动,信号槽已就绪,避免"线程已跑、接线还没好"
的竞态。
### ④ 优雅退出:`quit()` + `wait()`,别暴力 `terminate()`
- 让线程的事件循环自然结束:`thread->quit()`(请求退出)配 `deleteLater` 清理(见示例)。
- 若是 `QThread` 作为类成员:在析构里 `quit()` + `wait()`,确保线程真正停下再销毁对象。
- **绝不轻易用 `terminate()`**:它强行杀线程,可能导致资源未释放、锁未解开、数据损坏。
### ⑤ 亲和性决定槽的执行线程(再强调一次)
`connect` 里**接收者所在线程**决定了槽在哪执行(排队连接时)。当你疑惑"这个槽为什么
在主线程跑"——去查接收者的 `thread()`,答案就在那里。这是排查 Qt 线程问题的第一招。
### ⑥ 调试利器:`QThread::currentThread()`
任何时候想确认"现在我在哪个线程",打印 `QThread::currentThread()` 或与 `qApp->thread()`
比较。示例 3.1 就用它验证了回调确实在主线程。**排查多线程问题的第一行代码就是它。**
### 常见坑速览表
| 现象 | 根因 | 对应条目 |
|---|---|---|
| 界面卡死、"未响应" | 耗时操作在主线程 | 引言 / ① |
| 工作线程更新控件崩溃 | 子线程碰了 GUI | ① |
| moveToThread 后槽还在主线程跑 | 误用继承 QThread(方法一陷阱) | 二·方法一 / ⑤ |
| 程序退出时崩溃/卡死 | 线程未优雅退出 | ④ |
| 偶发数据错乱 | 多线程共享可变数据未加锁 | ② |
---
## 小结:一页速查表(2′)
**Qt 多线程的三条心法**
1. **动机**:多线程的首要目的是**界面不卡**(把耗时操作搬出主线程),其次是多核加速。
2. **核心抽象**:**线程亲和性**决定槽在哪个线程跑;**排队连接**自动把跨线程调用安全调度到目标线程。
3. **统一范式**:**子线程只算,结果用信号发回主线程,主线程负责一切后续(含更新 GUI)。**
**默认选型**:长期后台服务 → worker-object(方法二);批量并行任务 → QRunnable + 线程池(方法三)。
**四条铁律**:① 子线程不碰 GUI;② 优先信号槽传数据、慎用锁;③ moveToThread 先 connect 再 start、worker 无 parent;④ 优雅 quit+wait、禁用 terminate。
---
## 附:构建运行(一行说明)
上述示例为单 `.cpp` 控台程序。最小 CMake 片段:
```cmake
find_package(Qt5 COMPONENTS Core REQUIRED)
add_executable(thread_demo main.cpp)
target_link_libraries(thread_demo PRIVATE Qt5::Core)
set_target_properties(thread_demo PROPERTIES AUTOMOC ON) # 自动处理 moc,无需手写 main.moc
```
开启 `AUTOMOC` 后,示例末尾的 `#include "main.moc"` 可去掉,CMake 会自动生成元对象代码。
若把 Worker/SumTask 拆到独立的 `.h`,则无论哪种构建方式都无需手动处理 moc。
> **运行提示(看不到 qDebug 输出?)**:在 Windows + 官方**发行版(release** Qt 下,
> `qDebug` 默认走系统调试输出(`OutputDebugString`)而非终端,控制台程序可能看不到打印。
> 这**不是程序没执行**——线程照常运行、结果照常计算。验证手段任选其一:① 用 Debug 版 Qt;
> ② 临时改用 `std::cerr << ... << std::flush;` 打印关键路径;③ 在 IDE/调试器输出窗口查看。
> 示例逻辑的正确性不依赖此输出(上述三种示例的运行行为均已在 Qt 5.14.2 上端到端验证通过)。