From 993aa30a1c0691d7fc869923d57486bb35bfb5fa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BC=A0=E5=AE=97=E5=B9=B3?= Date: Thu, 9 Jul 2026 16:50:46 +0800 Subject: [PATCH] =?UTF-8?q?Mandelbrot=20=E6=95=99=E5=AD=A6=E7=A4=BA?= =?UTF-8?q?=E4=BE=8B=E5=A2=9E=E5=BC=BA=EF=BC=9A=E4=B8=BB=E7=BA=BF=E7=A8=8B?= =?UTF-8?q?=E6=B8=B2=E6=9F=93=E5=BE=AA=E7=8E=AF=E8=87=B3=20=E2=89=A51.5s?= =?UTF-8?q?=EF=BC=8C=E8=AE=A9=E7=95=8C=E9=9D=A2=E5=86=BB=E7=BB=93=E8=82=89?= =?UTF-8?q?=E7=9C=BC=E5=8F=AF=E6=84=9F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 实测单帧 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 --- .../2026-07-08-day4-mandelbrot-demo-design.md | 6 +- docs/teaching/MULTITHREADING.md | 366 ++++++++++++++++++ .../examples/mandelbrot_renderer/README.md | 6 + .../mandelbrot_renderer/mandelbrot_widget.cpp | 22 +- 4 files changed, 391 insertions(+), 9 deletions(-) create mode 100644 docs/teaching/MULTITHREADING.md diff --git a/docs/superpowers/specs/2026-07-08-day4-mandelbrot-demo-design.md b/docs/superpowers/specs/2026-07-08-day4-mandelbrot-demo-design.md index 6846129..582f2ff 100644 --- a/docs/superpowers/specs/2026-07-08-day4-mandelbrot-demo-design.md +++ b/docs/superpowers/specs/2026-07-08-day4-mandelbrot-demo-design.md @@ -72,8 +72,10 @@ wheelEvent 改 scale/center(以鼠标点为锚,缩放后该点复坐标不 ## 5. 三段式对比(教学核心) -- **「主线程渲染」按钮**:槽里同步调用 `renderStrip` 算一整帧(在 GUI 线程,不分块、不丢池),耗时约 1–3s。 - 期间事件循环停摆 → 按钮无响应、窗口拖不动 → 直观复现痛点。算完 `update()` 并显示「主线程渲染 Xms · 界面冻结」。 +- **「主线程渲染」按钮**:槽里在 GUI 线程同步调用 `renderStrip` 算整帧(不分块、不丢池)。 + 实测单帧 820×640@256 仅约 70ms,不足以肉眼可感,故**自适应循环渲染到累计 ≥ 1500ms**(实测 ~23 帧 / 1506ms)。 + 期间事件循环停摆 → 按钮无响应、窗口拖不动 → 直观复现痛点。算完 `update()` 并显示「主线程渲染 Xms · N 轮」。 + 冻结本质是事件循环被占用,循环只是模拟一个持续 ~1.5s 的耗时操作。 - **「单/多线程」切换**:复用同一套分块逻辑,仅切 `QThreadPool::globalInstance()->setMaxThreadCount(1 或 hardwareConcurrency())`。 单线程不卡但慢、多线程不卡且快,耗时标签量化加速比。 - 三段对比让学员一眼看懂:耗时任务必须离开主线程;多线程进一步压榨多核。 diff --git a/docs/teaching/MULTITHREADING.md b/docs/teaching/MULTITHREADING.md new file mode 100644 index 0000000..342eb74 --- /dev/null +++ b/docs/teaching/MULTITHREADING.md @@ -0,0 +1,366 @@ +# 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 +#include +#include +#include + +// 工作对象:普通 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 +#include +#include +#include +#include + +// 一个可并行执行的「任务」。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 上端到端验证通过)。 diff --git a/docs/teaching/examples/mandelbrot_renderer/README.md b/docs/teaching/examples/mandelbrot_renderer/README.md index a067157..4c8979f 100644 --- a/docs/teaching/examples/mandelbrot_renderer/README.md +++ b/docs/teaching/examples/mandelbrot_renderer/README.md @@ -21,6 +21,12 @@ 三段对比让两个结论同时成立:**耗时任务必须离开主线程**(否则冻结);**多线程进一步压榨多核**(加速比)。 +> **关于「主线程渲染」的冻结时长**:单帧 820×640@256 单线程仅约 **70ms**,远低于人眼可感阈值, +> 直接算一帧看不出「冻结」。故该按钮**自适应循环渲染直到累计 ≥ 1500ms**(实测约 23 帧 / 1506ms), +> 保证在任何 CPU 上冻结都肉眼可感。冻结的本质是「事件循环被占用」,与算什么无关——循环只是模拟一个 +> 持续 ~1.5s 的耗时操作。**演示技巧**:按下该按钮后立即拖动窗口或点击其它按钮,会发现整 1.5s 内 +> 完全无响应,这就是事件循环停摆。 + ## 概念映射 | Day4 知识点 | 本示例实现 | diff --git a/docs/teaching/examples/mandelbrot_renderer/mandelbrot_widget.cpp b/docs/teaching/examples/mandelbrot_renderer/mandelbrot_widget.cpp index 4c7dcd5..a0572e7 100644 --- a/docs/teaching/examples/mandelbrot_renderer/mandelbrot_widget.cpp +++ b/docs/teaching/examples/mandelbrot_renderer/mandelbrot_widget.cpp @@ -143,15 +143,23 @@ void MandelbrotWidget::onRenderOnMainThread() { pixmap_ = QImage(vp_.width, vp_.height, QImage::Format_RGB32); const int H = vp_.height; const int chunk = 24; // 分块调 renderStrip 只为复用同一纯函数 - for (int y = 0; y < H; y += chunk) { - const QImage s = renderStrip(vp_, pal_, y, qMin(y + chunk, H)); - QPainter p(&pixmap_); - p.drawImage(0, y, s); - } + // 自适应加重:单帧仅约几十毫秒(实测 820x640@256 ≈ 66ms),不足以让「冻结」肉眼可感。 + // 故循环渲染直到累计 ≥ 1500ms——冻结的本质是事件循环被占用,与「算什么」无关, + // 这模拟任意一个「占着 GUI 线程不返回事件循环」的耗时操作(解析大文件、复杂运算……)。 + // 上限 100 轮防止极慢机器卡死过久。 + int frames = 0; + do { + for (int y = 0; y < H; y += chunk) { + const QImage s = renderStrip(vp_, pal_, y, qMin(y + chunk, H)); + QPainter p(&pixmap_); + p.drawImage(0, y, s); + } + ++frames; + } while (timer_.elapsed() < 1500 && frames < 100); // 注意:本函数返回前事件循环不跑——期间按钮无响应、窗口拖不动、paintEvent 排队等待。 update(); - label_->setText(QString(QStringLiteral("主线程渲染 (界面冻结) · %1 ms")) - .arg(int(timer_.elapsed()))); + label_->setText(QString(QStringLiteral("主线程渲染 (界面冻结) · %1 ms · %2 轮")) + .arg(int(timer_.elapsed())).arg(frames)); } // ---- 单/多线程切换:复用同一套分块逻辑,仅切线程池大小 ----