FEM 仿真实时进度条:从需求到实现
需求背景
平台的仿真命令在执行时会先后跑两个外部进程:mesh(网格剖分)和 solver(求解器)。这两个过程加起来动辄几分钟到几十分钟,但 UI 上完全没有进度反馈——用户点一下”运行”,然后对着一个不动的窗口干等。
外部脚本本身是会写进度的:
- mesh 阶段会在
<workingDir>/<baseName>_imesh/mesher_progress.txt写入 0–100 的整数 - solver 阶段会在
<workingDir>/solver_progress.txt写入 0–100 的整数 - 每次写入都是 truncate 后重写一个数字,没有换行符
需求就是:监控这两个文件,把数字反映到全局进度条上,mesh 走完 0→100、隐藏、solver 再走 0→100。同时要考虑用户中途中断、外部进程异常退出等情况。
第一个错误尝试:复用 LogFileWatcher
项目里已经有一套成熟的文件监控机制 LogFileWatcher,是基于 OS 原生通知(Linux inotify、Windows ReadDirectoryChangesW)的事件驱动设计,用来监听 log 文件追加。看起来正好可以拿来用——直接 AddWatchFile 两个 progress 文件,订阅 FileWatchEventSource,在 onPostFileReloaded 里解析数字。
写完才发现:事件根本不会 fire。
翻 LogFileWatcher 的实现:
1 | |
progress 文件每次写入只有一个数字(如 "47")没有换行符,readLines 把它留在内部缓冲区不输出。OS 通知到了文件改动,但事件链在这里被吞掉。
这是工具与场景不匹配的典型表现——LogFileWatcher 假定监控的是 line-oriented 的日志流,而 progress 文件本质上是 content-oriented 的状态文件。强行把后者套进前者的语义是行不通的。
重新分层
干脆做一个新的组件,专门处理”内容变化”语义。但要保持架构一致:项目里所有的文件监控订阅者都是通过 FileWatchEventSource 这一个全局事件总线接收事件的,新组件也要走这条路,否则订阅模式就分裂成两套了。
最终方案分两层:
1 | |
TimeFileWatcher 跟 LogFileWatcher 是同一层级、同一命名空间下的兄弟组件,对外接口完全对齐(AddWatchFile / StartWatch / StopWatch),事件都通过 FileWatchEventSource 发布。对订阅者而言这两个 watcher 完全等价——只是底层一个用 OS 通知、一个用定时轮询。
TimeFileWatcher:跨平台轮询设计
为什么用轮询
进度文件特性决定了不能依赖 OS 事件——即使 OS 通知到了,line-oriented 的解析就会把单数字吞掉。轮询则简单粗暴:定时读取文件全量内容,内容变了就 fire 事件,不关心怎么变的、用什么方式变的。
代价是有 polling 间隔的延迟(200ms)和持续的 CPU 唤醒,但对几字节的小文件来说开销可以忽略。
跨平台
POSIX 和 Windows 各自的文件监控 API 完全不同,所以项目里 file_watcher_engine_posix.cpp 和 file_watcher_engine_win.cpp 是分平台实现的。但 polling 方案不存在这个问题——std::thread + std::ifstream + std::chrono 都是标准库,平台行为一致,零平台特定代码。
线程模型
1 | |
一条 worker 线程跑主循环,condition_variable::wait_for 让线程在间隔间睡眠,可被 notify 提前唤醒(用于响应 stop)。
std::atomic 用在单 bool 标志位(m_running、m_stop),单变量读写硬件保证原子。std::mutex 用在 m_entries(一个 map,多字段、容器结构修改不可能用原子做)。两种工具各管各的事——单 bool 用 atomic 比走 mutex 更省、更直观;复合状态用 mutex,保证一组相关字段的修改不被打断。
锁作用域的关键决策
worker 循环里有几段独立的临界区,每段都尽可能短:
1 | |
两条不能违反的规则:
- 慢操作不持锁——磁盘 I/O、外部回调可能任意长,持锁会让其他线程(比如想
AddWatchFile的)卡在那里 - 回调订阅者代码不持锁——订阅者可能反过来调我们的 API,持锁就形成”自己等自己”的死锁
第二条特别容易踩。事件总线 fire 的语义是同步广播,订阅者代码会在 worker 线程上跑。如果有一个订阅者在回调里调了 AddWatchFile,而我们恰好在 fire 的时候持锁,订阅者就永远拿不到锁、worker 线程永远不返回。
StopWatch 的防死锁
1 | |
m_stop 虽然是 atomic,但写它必须在 m_mutex 的保护下,否则跟 cv.wait_for 配合时有 race:
1 | |
condition variable 的标准用法范式:condition 变量的修改必须在 cv 关联的 mutex 内进行,即使本身是 atomic。
FemSimProgressWatcher:业务逻辑
底层 watcher 把”内容变了”这件事抽象出来后,业务层关心的就是怎么把内容映射到 UI。
双独立 indicator
两个进度本质上是两个 0–100,但都共享同一个全局进度条 UI。设计上每个 phase 一个独立的 ProgressMsgIndicator(0, 100):
1 | |
mesh 从 0 走到 100 时,indicator 内部触发 fireProgressReset 自动隐藏进度条;然后 solver 从 0 开始一个全新的 0→100。UI 那边的进度条逻辑(if value > 0 && value < 100 then show, else hide)不用改,自然就实现了”两段独立”的效果。
Phase 切换
最初的设计是”看到 solver 文件更新就切到 solver phase”。但仔细想:
- python 脚本可能在启动时就把两个进度文件都初始化成
0 - 那么 mesh 阶段同时也能读到 solver 文件的
0 - 如果直接切 phase,mesh 还没真正开始就被错误终结
修正后的判定:只有 solver 值 > 0 才切 phase。值为 0 的 solver 文件意味着”solver 还没真正开始工作”,留在 mesh 阶段。
1 | |
这条规则也自然兼容了 reuse_mesh / freq_sweep_only 的 resume 场景——mesh 整个被跳过,solver 第一个正值到达时直接切 phase。
单调性规则
1 | |
进度只允许递增,相等或更小的值丢弃。这条规则同时防御两种情况:
- 重复 fire:内容没变其实不会 fire,但万一被 fire 了,单调性会去重
- 部分写入读到的临时倒退值:python 正在把
"47"覆盖成"100"的瞬间,我们 polling 撞上只读到"1",单调性立刻拦下,避免进度条往回跳
初始值是 -1,所以第一次到达的 0 也能被接受。
异常清理与 RAII
外部 python 进程可能因为各种原因异常退出:崩溃、被 kill、用户点击 stop。核心设计原则是:异常退出不需要走独立的处理路径,复用正常销毁链。
1 | |
判断条件 [0, 100) 是关键:
| 场景 | mesh 最后值 | solver 最后值 | shutdown 行为 |
|---|---|---|---|
| 正常完成 | 100 | 100 | 都不 reset(已经 reset 过) |
| python 在 mesh 中崩溃 | 47 | -1 | reset mesh |
| python 在 solver 中崩溃 | 100 | 73 | reset solver |
| 用户点 stop | 27 或其他 | -1 | reset 当前阶段 |
| 启动失败 | -1 | -1 | 都不 reset(也没显示过) |
整个机制不需要”检测外部进程死活”——只要进度卡在中间值,就当作要清理。这个语义对所有异常路径都成立。
而 ~FemSimProgressWatcher 是由 unique_ptr 的析构自动触发的,进一步往上是命令组销毁触发。整条链是 RAII 自然展开:
1 | |
没有任何 try/catch、没有任何 if 异常判断——RAII 把”清理必须发生”这件事编译期就保证了。
Shutdown 的死锁陷阱
写 shutdown 时碰到一个微妙的问题。最初版本是这样:
1 | |
这会死锁。链路:
- main 线程在 shutdown,持有
m_mutex - 子线程正在
onPostFileReloaded里等同一把m_mutex - main 调
StopWatch()里面m_thread.join(),等子线程结束 - 子线程等 main 释放锁
- main 等子线程结束
- 锁被 main 持有
形成环。
join() 本质也是一种”等待”——等子线程终止。如果这个等待跟锁等待串联成环,就是死锁。解法是把 StopWatch 移到锁外:
1 | |
环断在第二步:StopWatch 期间 main 不持锁,子线程可以自由拿锁、做完回调、检查到 stop 后退出。
集成位置:放在哪一层
需要监控进度的 command 有两个:
eMesherflow:FemMeshProcessCommand(只写 mesher 文件)eEngineSolverflow:根据配置,mesh 可能由FemMeshProcessCommand写、solver 由FemEngineProcessCommand写;也可能两者都由FemEngineProcessCommand内部的 python 写
如果 watcher 在 command 里:
- 每个 command 自己建一个,phase 切换需要跨 command 实例协调
- 命令A 跑完时 watcher 销毁,命令B 启动时再建一个,状态不连续
如果 watcher 在 FemSimProcessCommandGroup 里:
- 一个 watcher 跨越所有命令的生命周期
- phase 切换在同一个对象内完成,干净
- watcher 是路径驱动的,不关心哪个命令在写文件
显然 group 层更合适——这跟现有 LogFileWatcher 的位置一致:
1 | |
顺手把裸指针清理一下
现有代码用 LogFileWatcher* 加析构里手动 delete——C++03 风格遗留。借这次机会改成 std::unique_ptr:
- 异常安全:init 中途抛异常自动清理
- 析构自动调用:不会忘记
- 所有权语义明确
unique_ptr<不完整类型> 有个小坑:析构必须定义在 .cpp 里(不能用默认 inline 析构),那里才能看到完整类型来合成 deleter。
完整事件流
最后串起来看:
1 | |
每一层都只关心自己那一层的事情。底层不知道有 mesh/solver 概念、不知道 0–100 含义;业务层不知道文件怎么读、不知道线程怎么调度;UI 层不知道事件从哪来、谁触发的。关注点分离做到位了,每一层独立测试、独立演化都容易。
一些工程权衡
为什么不用 boost::asio:LogFileWatcher 用了 asio,但 asio 的强项是多个异步源 multiplex 到一个线程上。我们这里只有一个定时轮询,condition_variable::wait_for 就够了,asio 反而是杀鸡用牛刀。两边内部实现不同,但外部接口对齐,使用方零感知。
为什么用事件总线而不是直接 callback:callback 更简单,但项目里所有 file watch 订阅者都走 FileWatchEventSource。统一模式的价值不只是”风格一致”,而是未来扩展性——比如想加一个 progress logger 把所有进度变化写审计文件,只要 subscribe 就行,watcher 不需要改。
为什么 polling 间隔是 200ms:用户感知阈值大约 100ms。200ms 在响应性和 CPU 唤醒频率之间是个常用折中。可以做成可配,但目前没需求。
总结
这个需求看起来简单(”加个进度条”),但牵涉到几个有意思的工程决策:
- 工具与场景匹配:现有 line-oriented watcher 不适合 content-oriented 场景,识别这种 mismatch 比强行套用现有工具更重要
- 分层与关注点分离:基础设施(polling)与业务逻辑(phase 切换、单调性)分离,跨层用统一的事件总线
- RAII 替代显式异常处理:把”清理必须发生”绑定到对象生命周期,异常路径与正常路径走同一套代码
- 死锁是设计问题不是 bug:锁与 join 的等待图必须是无环的,写并发代码时画依赖图比写完跑 stress test 更可靠
最终代码比第一版的”直接订阅事件总线”复杂度翻了几倍,但每一层增加的复杂度都对应着一个真实的工程约束。好的设计不是最简单的代码,是把复杂度分配到最合适位置的代码。