实测单帧 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>
19 KiB
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() 在子线程执行。
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 万"这件耗时计算 丢到子线程,算完把结果安全地传回主线程打印。
// 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 自动处理)
走读这个示例(这是本课重点):
- Worker 不继承 QThread——这是方法二的精髓。耗时逻辑在
doWork()槽里。 moveToThread改变亲和性:之后doWork在子线程执行。对比"方法一"的陷阱—— 那里只有run()在子线程,槽不在;这里doWork这个槽确确实实在子线程。started → doWork:线程的exec()一跑起来,started信号触发doWork,开始干活。resultReady → 主线程 lambda:因为接收者在主线程,Qt 自动用排队连接, 把"调用 lambda"投递回主线程。打印里的断言会显示"主线程"——亲手验证排队连接真的把回调带回来了。- 优雅退出:
resultReady → quit()让子线程事件循环退出 →finished触发 →deleteLater清理 worker 和 thread,最后app.quit()结束程序。没有内存泄漏,没有悬空。
如何亲手验证"排队连接"? 把第 4 条的回调想象成"更新一个 QLabel"。因为回调确定在主线程, 所以可以安全地操作 GUI 控件——这正是 worker 模式能安全配合 GUI 的根本原因。
3.2 完整示例:QRunnable + QThreadPool(任务并行)
同样是"1 加到 5000 万",这次切成 4 段丢给线程池并行算,4 段结果都回到主线程汇总。
// 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 多线程的三条心法:
- 动机:多线程的首要目的是界面不卡(把耗时操作搬出主线程),其次是多核加速。
- 核心抽象:线程亲和性决定槽在哪个线程跑;排队连接自动把跨线程调用安全调度到目标线程。
- 统一范式:子线程只算,结果用信号发回主线程,主线程负责一切后续(含更新 GUI)。
默认选型:长期后台服务 → worker-object(方法二);批量并行任务 → QRunnable + 线程池(方法三)。
四条铁律:① 子线程不碰 GUI;② 优先信号槽传数据、慎用锁;③ moveToThread 先 connect 再 start、worker 无 parent;④ 优雅 quit+wait、禁用 terminate。
附:构建运行(一行说明)
上述示例为单 .cpp 控台程序。最小 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 上端到端验证通过)。