Files
scuc-qt-course/docs/teaching/examples/mandelbrot_renderer/README.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

116 lines
7.7 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.
# Day4 Mandelbrot 分形渲染器 —— 事件 / 绘图 / 多线程 综合演示
> 对应 Day4 课程主线:**事件机制(事件 vs 信号、事件拦截)+ QPainter 绘图 + 多线程防 UI 阻塞**。
> 仓库 `p03/` 的 wiki 抓取内容里没有把这三者串起来的综合示例,本示例是手工补写的教学
> 补充,**不属于** `tools/gen_part3.py` 的生成产物。
## 一句话
滚轮缩放 / 拖拽平移探索 Mandelbrot 分形;分形渲染是 CPU 密集任务,用 `QThreadPool` +
`QRunnable` 分水平条带并行计算,主线程 `paintEvent` 只负责画图——**界面永不卡**。
再配一个「主线程渲染」按钮,**故意**在 GUI 线程同步算一帧,直观复现 Day4 slides 反复强调的
「主线程做耗时操作 = 事件循环停摆 = 整个界面冻结」。
## 三段式对比(本示例的核心教学价值)
| 操作 | 谁在算 | 事件循环 | 速度 | 你看到 |
|---|---|---|---|---|
| 「主线程渲染」按钮 | GUI 线程同步算 | **被占用→冻结** | 慢 | 按钮无响应、窗口拖不动、`paintEvent` 排队 |
| 「单线程」模式 | 线程池(1 线程)后台 | 空闲 | 慢 | 界面流畅、可继续缩放 |
| 「多线程」模式 | 线程池(N 核)后台 | 空闲 | **快 ~N 倍** | 界面流畅、渲染耗时骤降 |
三段对比让两个结论同时成立:**耗时任务必须离开主线程**(否则冻结);**多线程进一步压榨多核**(加速比)。
> **关于「主线程渲染」的冻结时长**:单帧 820×640@256 单线程仅约 **70ms**,远低于人眼可感阈值,
> 直接算一帧看不出「冻结」。故该按钮**自适应循环渲染直到累计 ≥ 1500ms**(实测约 23 帧 / 1506ms),
> 保证在任何 CPU 上冻结都肉眼可感。冻结的本质是「事件循环被占用」,与算什么无关——循环只是模拟一个
> 持续 ~1.5s 的耗时操作。**演示技巧**:按下该按钮后立即拖动窗口或点击其它按钮,会发现整 1.5s 内
> 完全无响应,这就是事件循环停摆。
## 概念映射
| Day4 知识点 | 本示例实现 |
|---|---|
| QPainter 绘图(框架驱动,只能在 `paintEvent` 里画) | `MandelbrotWidget::paintEvent` 把累积 `QImage` 一次性 `drawImage` |
| 事件处理(重写 `xxxEvent` | `wheelEvent`(缩放)、`mousePress/Move/ReleaseEvent`(平移)、`resizeEvent` |
| 多线程防 UI 阻塞 | `QThreadPool::globalInstance()` + `QRunnable` 分条带并行;排队信号槽回传结果 |
## 代码走读
1. **`mandelbrot_strip_task.h/.cpp`** —— 纯计算单元(可独立理解)
- `MandelViewport`:复平面视口参数;`MandelPalette`:迭代次数→RGB 调色板。
- `renderStrip(vp, pal, y0, y1)`**纯函数**,渲染视口内 `[y0,y1)` 行像素。多线程 task 与
「主线程渲染」按钮**共用**它——保证两路径算同一份分形,差异只在是否阻塞/是否并行。
- `MandelbrotStripTask``public QObject, public QRunnable` 多继承,`run()` 算完一条带
`emit stripReady(epoch, i, QImage)`。**任务持 palette/viewport 的值副本**,自包含、不引用
主线程对象(避免悬垂)。
2. **`mandelbrot_widget.h/.cpp`** —— 交互与渲染调度
- `scheduleRender()``++epoch_` → 切 `2×核数` 条带 → 每带 `new MandelbrotStripTask` 丢池。
- `onStripReady()`:校验 `epoch == epoch_`(过期丢弃)→ 把条带画到累积 `pixmap_``update()`
- `onRenderOnMainThread()`:在 GUI 线程同步循环算整帧,**不丢池** → 事件循环停摆 → 冻结。
- `onToggleThreadMode()``setMaxThreadCount(1 或 idealThreadCount())`,复用同一套分块逻辑。
3. **`main.cpp`** —— offscreen 平台下 200ms 自动退出(仓库自动化验证约定)。
## 关键机制:为什么界面不卡 / 为什么结果不会错乱
- **不卡**:耗时计算在 `QRunnable::run()`(子线程)里,主线程事件循环始终空闲,能继续响应
鼠标键盘。`stripReady` 信号经**排队连接**自动调度回主线程执行 `onStripReady`——不需要手动加锁。
- **不错乱**:连续缩放会产生多轮渲染。每轮 `++epoch_`;旧任务的 `stripReady` 回来时
`epoch != epoch_` 直接丢弃。这**不是主动取消**(旧任务仍在跑、算完即丢),但保证了画面正确——
**「多线程结果回收要防竞态」**本身就是 Day4 该讲的点。
## 构建与运行
### 方式 A:作为主仓库的一部分(仓库标准)
```bash
# Linux(隔离 Qt 5.14.2
cmake -S . -B build -G Ninja -DBUILD_QT_PART=ON \
-DCMAKE_PREFIX_PATH=$PWD/.qt514/5.14.2/gcc_64 -DCMAKE_BUILD_TYPE=Debug
cmake --build build --target qt_mandelbrot_renderer
tools/run_qt.sh qt_mandelbrot_renderer # 有屏交互
QT_QPA_PLATFORM=offscreen tools/run_qt.sh qt_mandelbrot_renderer # 自动退出码 0
```
### 方式 B:作为独立工程(可单独打包共享)
```bash
cmake -S docs/teaching/examples/mandelbrot_renderer -B build_mandel -G Ninja \
-DCMAKE_PREFIX_PATH=/path/to/qt/5.14.2/<kit> -DCMAKE_BUILD_TYPE=Debug
cmake --build build_mandel
./build_mandel/qt_mandelbrot_renderer
```
> 红线:无论哪种方式,Qt 必须是 5.14.x(本工程 CMakeLists 内置版本断言)。
> Linux 装隔离 Qt 见 `tools/install_qt_host.sh`Windows 用官方 Qt 5.14.2 MinGW 安装包。
## 踩坑点(代码里复现或规避)
1. **在 `paintEvent` 之外对【窗口 widget】构造 `QPainter` = 未定义行为**。本例严格区分:
对窗口 `this` 画只在 `paintEvent`;对离屏 `QImage pixmap_` 画(`onStripReady` / `onRenderOnMainThread`
是合法的——`QImage``QPaintDevice`,不受此限制。初学者常把两者混淆。
2. **`QRunnable` 没有 `Q_OBJECT`、没有信号**。要让任务发信号,须**多继承 `QObject`**(本例做法),
或在 `run()` 里用 `QMetaObject::invokeMethod(..., Qt::QueuedConnection)` 回调。继承 `QThread`
重写 `run()` 是另一条路但更易踩坑(见 `qthread_worker` 示例的对照说明)。
3. **主线程渲染卡死 vs 后台流畅的本质**:不是「多线程更快」那么简单,而是**事件循环是否被
占用**。按钮槽函数同步耗时 = 槽返回前事件循环不跑 = 所有事件(含重绘)排队 = 冻结。
4. **过期渲染结果覆盖新画面**:连续缩放时旧任务可能在新一轮之后才返回,必须用版本号
(本例 `epoch_`)过滤,否则画面会闪回旧帧。
5. **重写 `wheelEvent`/`mouseMoveEvent` 时是否调用基类**:本例需要继续向父传播的调用了
`QWidget::xxxEvent(e)`,自处理完毕不需要传播的(如 `wheelEvent` 缩放)则不调——这是
`accept()/ignore()` 的实践体现。
## 练习方向(供任务卡引用)
-`setMaxThreadCount` 从 1 逐步调到核数,画一张「线程数—渲染耗时」曲线,验证加速比的天花板。
- 给「主线程渲染」按钮加一个 `QApplication::processEvents()` 看似「不卡」的伪解法,观察它为什么
不可靠(仍是单线程、仍可能丢事件)——理解为什么必须用真线程。
- 把渲染中途的旧任务**真正取消**`QFuture`/`cancel` 标志位),对比当前「算完丢弃」的版本,
讨论 CPU 浪费与实现复杂度的权衡。
- 给分形加平滑着色(escape time + log 归一化),消除色带。
## 参考
- Qt 官方同名示例 *Mandelbrot Example*Qt Widgets / Painting)——本例改用「QThreadPool 分块 +
worker-object 思路」以贴合 Day4 多线程教学,官方版用 `RenderThread` 继承 `QThread`,可对照阅读。
- 配套概念示例:`qthread_worker/`worker-object + moveToThread 最小版)。