04-三频率解耦双线程与帧级去重 04 · 三频率解耦:双线程、帧级去重与锁策略上一篇讲了为什么 LogicEngine 吃字符串 id 而不是 int 槽位。这一篇讲平台化的另一条主线——三频率各管各的:CAN 帧从总线来,周期 50~200ms 不等LogicEngine tick 是 1Hz(把信号值算成 display / warn / light)UI binder(QtBinder)是 60Hz 轮询 snapshot 推 QML这三件事为什么必须解耦?怎么解耦?实测数据告诉了我们什么?为什么不直接一个线程一把锁走到底直觉方案:CAN 线程收到一帧 → 解码 → 喂 LogicEngine → tick → 推 UI。一行流,没锁,没双线程,简单清晰。现实:CAN 帧可能 50ms 一发(VCPU 车速),但 LogicEngine 的 tick故意做 1Hz(不想要高频触发)。假如车速 50ms 一发,tick 也 50ms 触发一次——指针会跳,报警会抖,日志会爆。tick 太低(比如 100ms)又会让isovertime这种超时判定粗糙:信号 200ms 没更新就判断超时,但 tick 100ms 一次根本来不及消错。加上 UI 端 Qt 渲染要 60Hz 拉数据(QTimer 16ms 一次),LogicEngine 算力跟不上 UI 拉取频率,反之 UI 拉取比 LogicEngine 输出快会导致空帧。三频率不是设计偏好,是真实物理频率逼出来的:总线 50ms、逻辑判断 1Hz、人眼 60Hz。三个频率,三个角色频率谁负责干什么CAN 周期 (50~200ms)CAN 读线程readFrame→ 帧级去重 → 解码 → 写SharedState.ctx1 HzLogicEngine tick 线程锁内 copy ctx →eng.tick(ctx)→ snapshot60 HzLogicDataSource (QTimer)锁内 copy ctx eng.snapshot()→ 组UiSnapshot→ 推 QtBinder → QML完整代码在src/main.cppsrc/main_ui.cppsrc/platform/logic_data_source.cpp。下面把每一段拉出来。频率 1:CAN 线程(主线程,src/main.cpp:84)// 主线程 CAN 收包 帧级去重 写 ctxwhile(!g_stop){CanFrame f;if(!srv.readFrame(f))break;booldupfalse;{std::lock_guardstd::mutexlk(shared.mtx);autoitshared.last_frame.find(f.can_id);if(it!shared.last_frame.end()std::memcmp(it-second.data(),f.data,f.dlc)0){duptrue;// ← 同 can_id 同 data,直接丢dup_count;}else{shared.last_frame[f.can_id]{};std::memcpy(shared.last_frame[f.can_id].data(),f.data,f.dlc);decodeFrameToCtx(f,shared.ctx);shared.last_update_ms/* now */;}}// print frame}关键设计:帧级去重放在 LogicEngine 入口之前。为什么不去重放在 socket 接收层?因为 socket 收包是 OS 的事,我们不知道 OS 收到的数据是不是 dup(虽然通常不会 dup),真正 dup 是总线节点重复发的——比如电机控制器一帧发两遍(丢包重传),或者调试器回灌历史数据。last_frame[can_id] memcmp是 60% 命中率的去重,实测 dup_count / frame_count 比值常常 60%。不去重会怎样?每条 dup 帧都会:覆盖m_lastUpdateMs[signal],让isovertime永远不会超时(因为时间戳不断被刷新)重复触发decodeFrameToCtx解析(浪费 CPU)触发m_signalByName之类的写(无害但冗余)所以去重是 LogicEngine 正确性的前提,不是优化。频率 2:LogicEngine tick 线程(1Hz,src/main.cpp:60)std::threadtick_thread([](){constautotick_periodstd::chrono::milliseconds(1000/TICK_HZ);// 1000msinttick_count0;while(!g_stop){std::this_thread::sleep_for(tick_period);// 1. 锁内 copy 一份 ctxstd::unordered_mapstd::string,doublesnapshot_ctx;int64_tlast_upd0;{std::lock_guardstd::mutexlk(shared.mtx);snapshot_ctxshared.ctx;last_updshared.last_update_ms;}// 2. 解锁后调 LogicEngine (它不知道有锁)eng.setClock([now_ms]{returnnow_ms;});eng.tick(snapshot_ctx);// ← 拿的是 copy,不是原 ctx// 3. 打印 snapshotprintTickSummary(tick_count,now_ms,eng.snapshot(),last_upd);}});关键设计:锁只覆盖copy,不覆盖tick。CAN 线程随时可能在 tick 线程持锁瞬间往shared.ctx写新值。tick 线程如果一边持锁一边跑 ExprTk 编译/求值,锁会持有几毫秒到几十毫秒——CAN 线程就被卡住,丢帧。所以锁内只做一件事:copy出一份新unordered_mapstring, double(snapshot_ctx),然后立刻解锁。eng.tick(snapshot_ctx)跑 ExprTk 时手里拿的是副本,跟 CAN 线程的 ctx 物理上脱钩。代价:每次 tick 复制一个 unordered_map。实测不慢——unordered_map的 copy 是 O(n) 内存块拷贝,几十个信号几微秒搞定。LogicEngine 真正的耗时是 ExprTk 求值(每条 expr 几微秒到几十微秒),跟锁无关。频率 3:UI binder 60Hz(src/platform/logic_data_source.cpp:46)boolLogicDataSource::start(){m_timer.start(UI_TICK_MS);// UI_TICK_MS 1000 / 60 16msm_runningtrue;onTick();returntrue;}voidLogicDataSource::onTick(){// 1. 锁内 copy ctx 拿 LogicEngine snapshotstd::unordered_mapstd::string,doublectx;int64_tlast_upd0;{std::lock_guardstd::mutexlk(m_shared-mtx);ctxm_shared-ctx;last_updm_shared-last_update_ms;}constautologic_snapm_engine-snapshot();// 2. 组装 UiSnapshotbuildSnapshot(ctx,logic_snap,now_ms,last_upd);// 3. 算 healthHealthStatus health...;emitHealthIfChanged(health);// 4. 推给 binderif(m_updateCb)m_updateCb(m_snapshot);}关键设计:60Hz 轮询 LogicEngine.snapshot(),不等 tick 推。LogicEngine.snapshot()是同步读,直接拿内部unordered_mapcopy 一份返回——不调任何 ExprTk,不持 LogicEngine 内部锁。60Hz 拉一次 1Hz 算出来的结果,QML 两次刷新之间看到的 snapshot 是一样的(LogicEngine 没动),这就是常说的逻辑节流、显示节流分离:业务逻辑 1Hz 算 → snapshot 稳定UI 60Hz 拉 → 平滑中间由 LogicDataSource 拍平 算 health 推 binderbuildSnapshot内部没多少事,主要是塞个meta.timestamp_ms / frame_seq / data_age_ms,数据直接 pointer-copy 自logic_snap和ctx。锁的边界把三个频率叠起来看锁:┌─ CAN 线程 (主线程) ────────────┐ │ lock → write ctx → unlock │ ← 持锁 1us │ loop readFrame │ └─────────────────────────────────┘ ┌─ tick 线程 (1Hz) ──────────────┐ │ lock → copy ctx → unlock │ ← 持锁 1us │ eng.tick(copy) │ ← 不持锁,跑 ExprTk 几十 us │ print snapshot │ └─────────────────────────────────┘ ┌─ LogicDataSource (60Hz) ───────┐ │ lock → copy ctx → unlock │ ← 持锁 1us │ eng.snapshot() (无锁) │ │ buildSnapshot │ │ push to binder → QML property │ ← 几十 us,QML 渲染异步 └─────────────────────────────────┘所有锁都 1us,锁竞争窗口短到 CAN 线程基本不会因此丢帧。如果用一把大锁护着从 CAN 到 tick 到 UI 推全流程,tick 一次 50ms(QML 渲染 ExprTk 全在里面),CAN 线程就被饿死了。实测:LatencyProbe 给的数字src/platform/latency_probe.h是个端到端延迟探针,从onCanRx(can frame 写 ctx 时刻)到onQmlPush(binder 推完 QML 时刻)算时间差。跑--bench-latency 3030 秒后输出: CAN → QML 延迟报告 (样本数20000) min : 1.20 ms avg : 16.30 ms p50 : 16.20 ms p99 : 16.40 ms max : 32.50 ms 注: 含 LogicDataSource 16ms (60fps) 轮询等待 数字解读:min 1.2ms—— 极端 case,刚好 CAN 帧落在 binder 推送前一瞬间,几乎无等待avg / p50 / p99 都 ~16ms——被 LogicDataSource 16ms 轮询拉平,意味着大多数情况下,CAN 帧的被看见要等下一次 binder 推送,平均等半拍max 32.5ms—— 偶尔赶上 binder 刚 tick 完,加 16ms 等下一拍,大概 2 个 UI 帧时间延迟下界就是 UI 帧周期 16ms,这没办法——QML 是 60Hz,推得再快也得等下一帧。如果想更短延迟,要么 QML 60→120Hz(代价是 CPU 翻倍),要么 LogicDataSource 改事件驱动(CAN 一来立刻 push,不再 16ms 轮询)——后者是平台演进,得动 c。一句话总结CAN、LogicEngine、UI 三件事各跑各的频率,中间用SharedState(短锁 副本)和LogicDataSource(60Hz 轮询)接起来,实测 CAN → QML p99 延迟 16ms 内。锁只护 copy,不护逻辑——这是这套架构最值的部分。如果业务规则加一条 expr在 tick 期间持有 SharedState 锁,整个 CAN 接收就被拖垮了。接下来下一篇 05 讲ui_backend报警聚合:warnid 触发怎么变成 QML 红色 banner / chime / 优先级排序——ConfigCatalog怎么补上 LogicEngine 不管的颜色/字号/优先级细节。06 DBC → can_ids.yaml 工具链:cantools 读 DBC 程序化注入 29-bit 帧 validate_dbc_yaml双向校验07 Qt 仪表实机 vs PC sim:QML 跨运行时差异 QmlLogger 桥

本周精选

本月热点