Files
张宗平 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

19 KiB
Raw Permalink Blame History

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 自动处理)

走读这个示例(这是本课重点)

  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 段结果都回到主线程汇总。

// 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.1worker-object):一个 Worker 长期住在一个子线程里,适合"后台服务"。
  • 示例 3.2(QRunnable):多个一次性任务被线程池调度,适合"批量并行"。
  • 两者都靠排队连接把结果安全带回主线程——这就是贯穿所有 Qt 多线程的统一范式: 子线程只管算,结果用信号发给主线程,主线程负责一切后续(更新界面/汇总/落盘)。

四、最佳实践与常见坑(8′)

每条都对应一个真实会崩或会卡的坑,逐条记住可省去项目期大量排查时间。

① 绝不在工作线程里访问 GUI 控件

QWidget 及其子类不是线程安全的,且只能由主线程操作。在工作线程里直接调 label->setText(...)未定义行为——可能崩溃、可能乱码、可能偶发花屏。

正解:子线程算出数据后 emit 一个信号,由主线程的槽(排队连接)去更新控件。 (回顾示例 3.1 的回调——它确认在主线程,所以那里才是碰 GUI 的合法位置。)

② 优先用信号槽传数据,退而求其次才用 QMutex

子线程之间、子线程与主线程之间,优先选择"通过信号槽传递数据的所有权"(一个线程 算完、发出去、就不再碰它),而不是"多个线程共享一块内存 + 加锁"。

只有当确实无法避免共享同一块可变数据时(如多个写线程更新同一个计数器),才用 QMutex(互斥锁)/ QReadWriteLock。锁是最后的手段,不是首选——它带来死锁、 性能下降、调试困难。

moveToThread 的两个时序要点

  • worker 不能有 parentnew 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 片段:

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 上端到端验证通过)。