派生曲线计算与 UI 绘制分离(后台线程 + 双缓冲),彻底消除交互卡顿

根因:重计算发生在绘制线程内(Paint 同步算派生预览曲线),一次整段弱波增强约 368ms 会阻塞消息循环,滑块/光标/视界失去跟手。

方案:新增后台计算线程 + 双缓冲。大窗口预览曲线不在绘制线程计算,本帧用最新旧曲线顶替并排后台任务;小窗口仍同步以保证预览实时;后台算完通过自定义消息通知宿主重绘(双缓冲切换)。条目用 shared_ptr 共享、绘制线程钉住整帧有效,后台只追加不改写,无数据竞争。任何改变数据层结构的操作前先 PauseDerivedAsync(join 后台线程),杜绝读取已释放/已移动采样。

实测 VERY_BIG_DATA(5 网格):弱波增强平移 2247ms→15ms,拖滑块 2303ms→15ms,静止重绘→0.25ms,交互均 ≤约 30ms,无 300ms/2s 级尖峰。小窗口同步路径与旧实现逐像素 0 差异。
This commit is contained in:
DeepSeek V4.1 Flash committed 2026-10-11 12:04:53 +08:00
1 parent aca84df0ac
commit 99247622c1
4 files changed
+520 -167

No files matched your search

+160 -36
View File
@@ -5,8 +5,13 @@
#include <objidl.h>
#include <gdiplus.h>
#include <atomic>
#include <condition_variable>
#include <deque>
#include <memory>
#include <mutex>
#include <string>
#include <thread>
#include <vector>
#include "DataModel.hpp"
@@ -16,6 +21,14 @@
namespace jdi
{
/**
* @brief 后台派生曲线计算完成后由 `PlotView` 投递给宿主的自定义消息。
*
* 宿主收到后重绘绘图区即可:绘制线程会在下一帧取到刚算好的曲线(双缓冲切换),
* 同时静态场景缓存会被作废以纳入新曲线。`WM_APP + 3` 与宿主其它自定义消息错开。
*/
constexpr UINT kMessagePlotDerivedReady = WM_APP + 3;
/**
* @brief 绘图区拖拽交互模式。
*/
@@ -360,6 +373,16 @@ namespace jdi
*/
void InvalidateDerivedCaches();
/**
* @brief 暂停后台派生曲线计算并作废其结果。
*
* 后台线程会读取采样数据,因此**任何会改变数据层结构(增删文件/信号、插入派生信号,
* 进而移动 `DataFile`/`SignalData` 存储)的操作之前**都必须先调用本函数:它会等待正在
* 进行的计算结束、清空待算队列与缓存,从而杜绝后台线程读取已释放内存。
* 之后的绘制会按需重新启动后台线程(惰性)。
*/
void PauseDerivedAsync();
/**
* @brief 三态循环切换时间光标:无 → 单光标 → 双光标 → 无。
* @return 状态发生变化返回 true。
@@ -1264,17 +1287,133 @@ namespace jdi
* @param [IN] begin 需要的采样区间起点(含)。
* @param [IN] end 需要的采样区间终点(不含)。
* @param [OUT] spanBegin 返回数组对应的首个采样下标。
* @param [IN] allowInteractiveDefer 交互期间是否允许「延迟重算」:窗口过大时复用已算好的
* 旧曲线,避免在拖动滑块/移动视界时同步重算整条曲线(见
* `kDerivedInteractiveComputeBudget`)。仅供**预览绘制**传 true;
* 需要精确取值的统计(中心轴边沿计数)必须传 false。
* @return 曲线数组指针(覆盖 `[spanBegin, spanBegin + size())`);无法计算时返回 nullptr。
* @param [IN] allowAsyncCompute 是否允许「后台异步计算」:窗口很大时**不在绘制线程里计算**,
* 而是交后台线程算,本帧先用已算好的旧曲线顶替(双缓冲),保证交互始终跟手。
* 仅供**预览绘制**传 true;需要精确取值的统计(中心轴边沿计数、取值胶囊)
* 传 false,走同步精确计算。
* @return 曲线数组指针(覆盖 `[spanBegin, spanBegin + size())`);暂无可显示曲线时返回 nullptr。
*/
const std::vector<float>* CurveSpanFor(DerivedCurveKind kind, const SignalData& signal,
int points, int iterations, int levelPercent,
std::size_t begin, std::size_t end,
std::size_t& spanBegin,
bool allowInteractiveDefer = false) const;
bool allowAsyncCompute = false) const;
/**
* @brief 派生曲线缓存的键(决定一条派生曲线数值的全部输入)。
*
* 数据层在两次结构变化之间是只读的,因此 `signal` 指针在此期间稳定;
* 任何结构变化(增删文件/信号、插入派生信号、切换标签页)前都会暂停后台线程并清空缓存
*(见 `PauseDerivedAsync`),因此不会出现悬空读取。
*/
struct CurveKey
{
const SignalData* signal = nullptr; /**< 目标信号。 */
int kind = 0; /**< `DerivedCurveKind`。 */
int points = 5; /**< 均值点数 X。 */
int iterations = 1; /**< 迭代次数 N。 */
int levelPercent = 0; /**< 弱波增强等级(0–100)。 */
bool operator==(const CurveKey& other) const
{
return signal == other.signal && kind == other.kind && points == other.points &&
iterations == other.iterations && levelPercent == other.levelPercent;
}
};
/**
* @brief 一条已算好的派生曲线窗口(发布后**不可变**)。
*
* 用 `shared_ptr<const CurveEntry>` 持有:绘制线程取到后钉在 `m_curvePins` 中,整帧都引用
* 同一块内存;后台线程只**追加**新条目、从不修改已发布条目,因此「显示旧缓冲、后台算新缓冲」
* 的双缓冲模型天然无数据竞争。
*/
struct CurveEntry
{
CurveKey key; /**< 曲线键。 */
std::size_t begin = 0; /**< 已计算窗口起点(采样下标)。 */
std::size_t end = 0; /**< 已计算窗口终点(不含)。 */
std::uint64_t serial = 0; /**< 发布序号(越大越新,用于选出最新覆盖项)。 */
std::vector<float> values; /**< 曲线数值(长度 = end - begin)。 */
};
using CurveEntryPtr = std::shared_ptr<const CurveEntry>;
/** @brief 后台派生曲线计算任务(`windowBegin/End` 已含保护带)。 */
struct CurveJob
{
CurveKey key; /**< 曲线键。 */
std::size_t windowBegin = 0; /**< 计算窗口起点(含保护带)。 */
std::size_t windowEnd = 0; /**< 计算窗口终点(不含)。 */
std::uint64_t generation = 0; /**< 入队时的数据代次(代次变化即丢弃结果)。 */
};
/**
* @brief 计算派生曲线「含保护带」的实际计算窗口(同步与后台共用同一公式)。
* @param [IN] kind 曲线种类(均值滤波的保护带需覆盖滤波半径)。
* @param [IN] alignedBegin 对齐后的需要区间起点。
* @param [IN] alignedEnd 对齐后的需要区间终点(不含)。
* @param [IN] points 均值点数 X。
* @param [IN] iterations 迭代次数 N。
* @param [IN] count 采样点总数。
* @param [OUT] begin 计算窗口起点。
* @param [OUT] end 计算窗口终点(不含)。
*/
void DerivedGuardWindow(DerivedCurveKind kind, std::size_t alignedBegin, std::size_t alignedEnd,
int points, int iterations, std::size_t count, std::size_t& begin,
std::size_t& end) const;
/**
* @brief 取得覆盖 `[begin, end]` 的最新已算好条目并钉住(防止本帧被回收)。
* @param [IN] key 曲线键。
* @param [IN] begin 需要区间起点。
* @param [IN] end 需要区间终点(不含)。
* @param [OUT] spanBegin 命中条目的窗口起点。
* @return 命中条目,未命中返回 nullptr。
*/
const CurveEntry* AcquireCoveringCurve(const CurveKey& key, std::size_t begin, std::size_t end,
std::size_t& spanBegin) const;
/**
* @brief 取得该曲线键下最新的已算好条目并钉住(双缓冲的「旧缓冲」)。
* @param [IN] key 曲线键。
* @param [OUT] spanBegin 命中条目的窗口起点。
* @return 命中条目,无则返回 nullptr。
*/
const CurveEntry* AcquireNewestCurve(const CurveKey& key, std::size_t& spanBegin) const;
/**
* @brief 发布一条新算好的曲线窗口并钉住。
* @param [IN] key 曲线键。
* @param [IN] begin 窗口起点。
* @param [IN] end 窗口终点(不含)。
* @param [IN] values 曲线数值(移动入条目)。
* @param [OUT] spanBegin 发布条目的窗口起点。
* @return 发布条目的只读指针。
*/
const CurveEntry* PublishCurve(const CurveKey& key, std::size_t begin, std::size_t end,
std::vector<float>&& values, std::size_t& spanBegin) const;
/**
* @brief 把一个派生曲线计算任务排入后台队列(同一曲线键只保留一个在算任务)。
* @param [IN] key 曲线键。
* @param [IN] alignedBegin 对齐后的需要区间起点。
* @param [IN] alignedEnd 对齐后的需要区间终点(不含)。
* @param [IN] count 采样点总数。
*/
void ScheduleCurveJob(const CurveKey& key, std::size_t alignedBegin, std::size_t alignedEnd,
std::size_t count) const;
/** @brief 惰性启动后台计算线程。 */
void EnsureCurveWorker() const;
/** @brief 停止并回收后台计算线程(等待在算任务结束)。 */
void StopCurveWorker() const;
/** @brief 后台计算线程主体。 */
void CurveWorkerLoop() const;
/** @brief 清理已被更新条目覆盖或超出内存预算的缓存条目(调用方须持有 `m_curveMutex`)。 */
void PruneCurveEntriesLocked() const;
/**
* @brief 计算当前视图对应的采样下标区间(时间单调时二分定位,否则为整段)。
@@ -1600,36 +1739,21 @@ namespace jdi
};
mutable std::deque<WeakBoostCurveEntry> m_weakBoostCurveCache;
/**
* @brief 派生曲线的「区间窗口」缓存项。
*
* `values[i]` 对应采样下标 `begin + i`;`begin/end` 是**实际计算**的区间
* (已向外扩了保护带),命中条件是「参数一致且窗口包含所需区间」。
*/
struct CurveSpanEntry
{
const SignalData* signal = nullptr; /**< 目标信号。 */
int kind = 0; /**< `DerivedCurveKind`。 */
int points = 5; /**< 均值点数 X。 */
int iterations = 1; /**< 迭代次数 N。 */
int levelPercent = 0; /**< 弱波增强等级(0–100)。 */
std::size_t begin = 0; /**< 已计算区间的起点(采样下标)。 */
std::size_t end = 0; /**< 已计算区间的终点(不含)。 */
std::vector<float> values; /**< 曲线数值。 */
};
mutable std::deque<CurveSpanEntry> m_curveSpanCache; /**< 区间窗口缓存(deque 保证引用稳定)。 */
/**
* @brief 「超大窗口」派生曲线的一次性缓冲。
*
* 中心轴边沿计数在**没有限定光标区间**时要统计整条信号,对应窗口与信号等长
*(亿级采样 ≈ 100MB/信号)。这类窗口绝不进 `m_curveSpanCache`:
* 一旦被淘汰,下一帧就会重算整条曲线(实测单次 ≈ 1s 的卡顿尖峰),
* 而且几个信号同时驻留会吃掉数百 MB。
* 改为写入本缓冲:调用方(`EdgeCenterCountsFor`)在其返回后立即用完,
* 因此生命周期安全。⚠️ 指针仅在下一次「超大窗口」请求之前有效。
*/
mutable std::vector<float> m_curveSpanScratch;
// ---- 异步派生曲线计算(后台线程 + 双缓冲)----
// 设计:预览曲线(均值滤波 / 弱波增强)在窗口很大时**不在绘制线程里计算**,而是交给后台
// 线程;绘制线程本帧先用上一条已算好的曲线顶替(双缓冲),后台算完后通过
// `kMessagePlotDerivedReady` 通知宿主重绘,从而切换为最新曲线。这样滑块 / 光标 / 视界 /
// 缩放等交互的反馈始终跟手,不会被计算拖住,同时把重计算放到后台充分利用多核。
mutable std::mutex m_curveMutex; /**< 保护任务队列与已算好条目。 */
mutable std::condition_variable m_curveCv; /**< 唤醒后台线程。 */
mutable std::thread m_curveWorker; /**< 后台计算线程(惰性启动)。 */
mutable bool m_curveWorkerStop = false; /**< 请求后台线程退出。 */
mutable std::uint64_t m_curveGeneration = 0; /**< 数据代次(结构变化时自增)。 */
mutable std::uint64_t m_curveSerial = 0; /**< 已发布条目序号。 */
mutable std::deque<CurveJob> m_curveJobs; /**< 待计算任务。 */
mutable std::deque<CurveEntryPtr> m_curveEntries; /**< 已算好条目(双缓冲的「就绪缓冲」)。 */
mutable std::vector<CurveEntryPtr> m_curvePins; /**< 本帧正在使用的条目(阻止其被回收)。 */
mutable std::atomic<bool> m_curveSceneDirty{false}; /**< 后台发布了新条目 → 需重建静态场景缓存。 */
/** 中心轴边沿统计缓存项(增量统计机制与 `EdgeCountEntry` 完全一致)。 */
struct EdgeCenterCountEntry