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>
7.7 KiB
7.7 KiB
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 分条带并行;排队信号槽回传结果 |
代码走读
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 的值副本,自包含、不引用 主线程对象(避免悬垂)。
mandelbrot_widget.h/.cpp—— 交互与渲染调度scheduleRender():++epoch_→ 切2×核数条带 → 每带new MandelbrotStripTask丢池。onStripReady():校验epoch == epoch_(过期丢弃)→ 把条带画到累积pixmap_→update()。onRenderOnMainThread():在 GUI 线程同步循环算整帧,不丢池 → 事件循环停摆 → 冻结。onToggleThreadMode():setMaxThreadCount(1 或 idealThreadCount()),复用同一套分块逻辑。
main.cpp—— offscreen 平台下 200ms 自动退出(仓库自动化验证约定)。
关键机制:为什么界面不卡 / 为什么结果不会错乱
- 不卡:耗时计算在
QRunnable::run()(子线程)里,主线程事件循环始终空闲,能继续响应 鼠标键盘。stripReady信号经排队连接自动调度回主线程执行onStripReady——不需要手动加锁。 - 不错乱:连续缩放会产生多轮渲染。每轮
++epoch_;旧任务的stripReady回来时epoch != epoch_直接丢弃。这不是主动取消(旧任务仍在跑、算完即丢),但保证了画面正确—— **「多线程结果回收要防竞态」**本身就是 Day4 该讲的点。
构建与运行
方式 A:作为主仓库的一部分(仓库标准)
# 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:作为独立工程(可单独打包共享)
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 安装包。
踩坑点(代码里复现或规避)
- 在
paintEvent之外对【窗口 widget】构造QPainter= 未定义行为。本例严格区分: 对窗口this画只在paintEvent;对离屏QImage pixmap_画(onStripReady/onRenderOnMainThread) 是合法的——QImage是QPaintDevice,不受此限制。初学者常把两者混淆。 QRunnable没有Q_OBJECT、没有信号。要让任务发信号,须多继承QObject(本例做法), 或在run()里用QMetaObject::invokeMethod(..., Qt::QueuedConnection)回调。继承QThread重写run()是另一条路但更易踩坑(见qthread_worker示例的对照说明)。- 主线程渲染卡死 vs 后台流畅的本质:不是「多线程更快」那么简单,而是事件循环是否被 占用。按钮槽函数同步耗时 = 槽返回前事件循环不跑 = 所有事件(含重绘)排队 = 冻结。
- 过期渲染结果覆盖新画面:连续缩放时旧任务可能在新一轮之后才返回,必须用版本号
(本例
epoch_)过滤,否则画面会闪回旧帧。 - 重写
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 最小版)。