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
2
3
4
5
6
7
8
9
// 内部的 readLines —— 只输出完整的、以 \n 结尾的行
while ((posEnd = m_lineBuffer.find('\n', posStart)) != npos) {
lines.push_back(...);
}

// 调用方 readAndFireEvent —— lines 为空就不 fire
if (!lines.empty() || currSize <= 0) {
firePostFileReloaded(...);
}

progress 文件每次写入只有一个数字(如 "47")没有换行符,readLines 把它留在内部缓冲区不输出。OS 通知到了文件改动,但事件链在这里被吞掉。

这是工具与场景不匹配的典型表现——LogFileWatcher 假定监控的是 line-oriented 的日志流,而 progress 文件本质上是 content-oriented 的状态文件。强行把后者套进前者的语义是行不通的。

重新分层

干脆做一个新的组件,专门处理”内容变化”语义。但要保持架构一致:项目里所有的文件监控订阅者都是通过 FileWatchEventSource 这一个全局事件总线接收事件的,新组件也要走这条路,否则订阅模式就分裂成两套了。

最终方案分两层:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
┌─────────────────────────────────────────┐
│ FemSimProgressWatcher (业务层) │
│ - 解析数字、维护 mesh/solver phase │
│ - 驱动 ProgressMsgIndicator │
└─────────────────┬───────────────────────┘
│ subscribe
┌─────────────────▼───────────────────────┐
│ FileWatchEventSource (事件总线) │
└─────────────────▲───────────────────────┘
│ fire
┌─────────────────┴───────────────────────┐
│ TimeFileWatcher (新增·基础设施层) │
│ - 定时轮询文件全量内容 │
│ - 内容变化时通过事件总线分发 │
└─────────────────────────────────────────┘

TimeFileWatcherLogFileWatcher 是同一层级、同一命名空间下的兄弟组件,对外接口完全对齐(AddWatchFile / StartWatch / StopWatch),事件都通过 FileWatchEventSource 发布。对订阅者而言这两个 watcher 完全等价——只是底层一个用 OS 通知、一个用定时轮询。

TimeFileWatcher:跨平台轮询设计

为什么用轮询

进度文件特性决定了不能依赖 OS 事件——即使 OS 通知到了,line-oriented 的解析就会把单数字吞掉。轮询则简单粗暴:定时读取文件全量内容,内容变了就 fire 事件,不关心怎么变的、用什么方式变的

代价是有 polling 间隔的延迟(200ms)和持续的 CPU 唤醒,但对几字节的小文件来说开销可以忽略。

跨平台

POSIX 和 Windows 各自的文件监控 API 完全不同,所以项目里 file_watcher_engine_posix.cppfile_watcher_engine_win.cpp 是分平台实现的。但 polling 方案不存在这个问题——std::thread + std::ifstream + std::chrono 都是标准库,平台行为一致,零平台特定代码。

线程模型

1
2
3
4
5
6
7
8
9
10
class TimeFileWatcher {
private:
std::chrono::milliseconds m_interval;
std::unordered_map<std::string, Entry> m_entries;
std::mutex m_mutex;
std::condition_variable m_cv;
std::atomic<bool> m_running{false};
std::atomic<bool> m_stop{false};
std::thread m_thread;
};

一条 worker 线程跑主循环,condition_variable::wait_for 让线程在间隔间睡眠,可被 notify 提前唤醒(用于响应 stop)。

std::atomic 用在单 bool 标志位(m_runningm_stop),单变量读写硬件保证原子。std::mutex 用在 m_entries(一个 map,多字段、容器结构修改不可能用原子做)。两种工具各管各的事——单 bool 用 atomic 比走 mutex 更省、更直观;复合状态用 mutex,保证一组相关字段的修改不被打断。

锁作用域的关键决策

worker 循环里有几段独立的临界区,每段都尽可能短

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
void workerLoop() {
while (true) {
// 1. wait(cv.wait_for 内部短暂持锁、释放、再加锁)
{
std::unique_lock<std::mutex> lock(m_mutex);
m_cv.wait_for(lock, m_interval, [this]{ return m_stop.load(); });
if (m_stop.load()) return;
}

// 2. 快照文件列表(短锁)
std::vector<std::string> snapshot;
{
std::lock_guard<std::mutex> lock(m_mutex);
// copy keys
}

// 3. 读文件(无锁,慢 I/O 不能持锁)
for (auto& file : snapshot) {
if (m_stop.load()) return;
std::string content;
if (!readFileContent(file, content)) continue;

// 4. 检查是否变化、更新缓存(短锁)
bool fire = false;
{
std::lock_guard<std::mutex> lock(m_mutex);
// compare and update
}

// 5. fire 事件(无锁,订阅者代码不可控)
if (fire) {
FileWatchEventSource::firePostFileReloaded(file, ...);
}
}
}
}

两条不能违反的规则:

  1. 慢操作不持锁——磁盘 I/O、外部回调可能任意长,持锁会让其他线程(比如想 AddWatchFile 的)卡在那里
  2. 回调订阅者代码不持锁——订阅者可能反过来调我们的 API,持锁就形成”自己等自己”的死锁

第二条特别容易踩。事件总线 fire 的语义是同步广播,订阅者代码会在 worker 线程上跑。如果有一个订阅者在回调里调了 AddWatchFile,而我们恰好在 fire 的时候持锁,订阅者就永远拿不到锁、worker 线程永远不返回。

StopWatch 的防死锁

1
2
3
4
5
6
7
8
9
void StopWatch() {
if (!m_running.exchange(false)) return;
{
std::lock_guard<std::mutex> lock(m_mutex);
m_stop.store(true);
}
m_cv.notify_all();
if (m_thread.joinable()) m_thread.join();
}

m_stop 虽然是 atomic,但写它必须在 m_mutex 的保护下,否则跟 cv.wait_for 配合时有 race:

1
2
3
4
5
worker:进入 wait_for,正要释放锁、睡觉
main:m_stop = true
main:notify_all ← 还没人在 wait
worker:终于睡着了
worker:永远等

condition variable 的标准用法范式:condition 变量的修改必须在 cv 关联的 mutex 内进行,即使本身是 atomic。

FemSimProgressWatcher:业务逻辑

底层 watcher 把”内容变了”这件事抽象出来后,业务层关心的就是怎么把内容映射到 UI。

双独立 indicator

两个进度本质上是两个 0–100,但都共享同一个全局进度条 UI。设计上每个 phase 一个独立的 ProgressMsgIndicator(0, 100)

1
2
std::unique_ptr<ProgressMsgIndicator> m_meshIndicator;
std::unique_ptr<ProgressMsgIndicator> m_solverIndicator;

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
2
3
4
5
if (m_currentPhase == eMesh) {
if (value <= 0) return; // solver 文件存在但没真正开始
m_meshIndicator->reset("");
m_currentPhase = eSolver;
}

这条规则也自然兼容了 reuse_mesh / freq_sweep_only 的 resume 场景——mesh 整个被跳过,solver 第一个正值到达时直接切 phase。

单调性规则

1
if (value <= m_lastMeshValue) return;

进度只允许递增,相等或更小的值丢弃。这条规则同时防御两种情况:

  1. 重复 fire:内容没变其实不会 fire,但万一被 fire 了,单调性会去重
  2. 部分写入读到的临时倒退值:python 正在把 "47" 覆盖成 "100" 的瞬间,我们 polling 撞上只读到 "1",单调性立刻拦下,避免进度条往回跳

初始值是 -1,所以第一次到达的 0 也能被接受。

异常清理与 RAII

外部 python 进程可能因为各种原因异常退出:崩溃、被 kill、用户点击 stop。核心设计原则是:异常退出不需要走独立的处理路径,复用正常销毁链

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
~FemSimProgressWatcher() {
shutdown();
}

void shutdown() {
// 1. 停 TimeFileWatcher worker(join)
// ...

// 2. 检查每个 indicator:没自然走到 100 的就 reset
if (m_lastMeshValue >= 0 && m_lastMeshValue < 100) {
m_meshIndicator->reset("");
}
if (m_lastSolverValue >= 0 && m_lastSolverValue < 100) {
m_solverIndicator->reset("");
}
}

判断条件 [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
2
3
4
5
6
7
8
9
python 进程退出(任何原因)
→ ProcessRunner::run() 返回
→ ProcessCommand::run() 返回
→ ProcessCommandGroup::onRun() 退出
→ ProcessCommandGroupThread::onFinished()
→ 上层调 destroy() → delete m_cmdGroup
→ ~FemSimProcessCommandGroup
→ unique_ptr<FemSimProgressWatcher> 析构
→ ~FemSimProgressWatcher → shutdown() → reset 进度条

没有任何 try/catch、没有任何 if 异常判断——RAII 把”清理必须发生”这件事编译期就保证了。

Shutdown 的死锁陷阱

写 shutdown 时碰到一个微妙的问题。最初版本是这样:

1
2
3
4
5
6
7
8
// 错误版本
void shutdown() {
std::lock_guard<std::mutex> lock(m_mutex);
if (m_timeFileWatcher) {
m_timeFileWatcher->StopWatch(); // 持锁调 join
}
// ...
}

这会死锁。链路:

  1. main 线程在 shutdown,持有 m_mutex
  2. 子线程正在 onPostFileReloaded 里等同一把 m_mutex
  3. main 调 StopWatch() 里面 m_thread.join(),等子线程结束
  4. 子线程等 main 释放锁
  5. main 等子线程结束
  6. 锁被 main 持有

形成环。

join() 本质也是一种”等待”——等子线程终止。如果这个等待跟锁等待串联成环,就是死锁。解法是把 StopWatch 移到锁外:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
void shutdown() {
// 先把 watcher 摘出来(锁内)
std::unique_ptr<TimeFileWatcher> watcher;
{
std::lock_guard<std::mutex> lock(m_mutex);
watcher = std::move(m_timeFileWatcher);
}

// 锁外 StopWatch(子线程能正常拿锁、跑完回调、退出)
if (watcher) {
watcher->StopWatch();
}

// 子线程已经 join,没人会再 fire 事件
std::lock_guard<std::mutex> lock(m_mutex);
// 安全地 reset indicators
}

环断在第二步:StopWatch 期间 main 不持锁,子线程可以自由拿锁、做完回调、检查到 stop 后退出。

集成位置:放在哪一层

需要监控进度的 command 有两个:

  • eMesher flow:FemMeshProcessCommand(只写 mesher 文件)
  • eEngineSolver flow:根据配置,mesh 可能由 FemMeshProcessCommand 写、solver 由 FemEngineProcessCommand 写;也可能两者都由 FemEngineProcessCommand 内部的 python 写

如果 watcher 在 command 里:

  • 每个 command 自己建一个,phase 切换需要跨 command 实例协调
  • 命令A 跑完时 watcher 销毁,命令B 启动时再建一个,状态不连续

如果 watcher 在 FemSimProcessCommandGroup 里:

  • 一个 watcher 跨越所有命令的生命周期
  • phase 切换在同一个对象内完成,干净
  • watcher 是路径驱动的,不关心哪个命令在写文件

显然 group 层更合适——这跟现有 LogFileWatcher 的位置一致:

1
2
3
4
5
class FemSimProcessCommandGroup : public ProcessCommandGroup {
private:
std::unique_ptr<base::watch::LogFileWatcher> m_logFileWatcher;
std::unique_ptr<FemSimProgressWatcher> m_progressWatcher;
};

顺手把裸指针清理一下

现有代码用 LogFileWatcher* 加析构里手动 delete——C++03 风格遗留。借这次机会改成 std::unique_ptr

  • 异常安全:init 中途抛异常自动清理
  • 析构自动调用:不会忘记
  • 所有权语义明确

unique_ptr<不完整类型> 有个小坑:析构必须定义在 .cpp 里(不能用默认 inline 析构),那里才能看到完整类型来合成 deleter。

完整事件流

最后串起来看:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
python 进程写 progress 文件


TimeFileWatcher worker thread (每 200ms polling)


检测到内容变化


FileWatchEventSource::firePostFileReloaded(file, content)


FemSimProgressWatcher::onPostFileReloaded


路径过滤 → 解析整数 → solver phase 守门 (>0) → 单调性检查


ProgressMsgIndicator::stepTo(value)


ProgressEventSource::fireProgressChanged


ProgressBar::onProgressChanged → setValue(UI 更新)

每一层都只关心自己那一层的事情。底层不知道有 mesh/solver 概念、不知道 0–100 含义;业务层不知道文件怎么读、不知道线程怎么调度;UI 层不知道事件从哪来、谁触发的。关注点分离做到位了,每一层独立测试、独立演化都容易

一些工程权衡

为什么不用 boost::asioLogFileWatcher 用了 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 更可靠

最终代码比第一版的”直接订阅事件总线”复杂度翻了几倍,但每一层增加的复杂度都对应着一个真实的工程约束。好的设计不是最简单的代码,是把复杂度分配到最合适位置的代码


FEM 仿真实时进度条:从需求到实现
http://aojian-blog.oss-cn-wuhan-lr.aliyuncs.com/2026/05/12/progress_watcher/
作者
遨见
发布于
2026年5月12日
许可协议