993aa30a1c
实测单帧 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>
116 lines
7.7 KiB
Markdown
116 lines
7.7 KiB
Markdown
# 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 最小版)。
|