Qt 实时数据可视化工程实践:环形缓冲区实践
目录前言一、架构设计1.1 分层架构图1.2 数据写入流1.3 数据刷新流 (定时器驱动 → 视图更新)1.4 核心设计思想二、核心实现详解2.1 RingBuffer环形缓冲区实现2.1.1 append函数线程安全写入函数主体实现第一行QWriteLocker locker(bufferLock)第2-3行数组直接索引写入第4行环形回绕核心逻辑第5行有效数据量管理内存状态演变图解2.1.2 copyTo函数函数主体实现第一行QReadLocker locker(bufferLock) -读写锁QReadLocker第二、三行resize预分配内存四 - 九行循环计算索引写入示例演算承接上面的 F、B、C、D、E情况一缓冲区未满逻辑顺序旧→新A(0)、B(1)、C(2)、[ ]、[ ]情况二缓冲区刚好满情况三缓冲区满且回绕2.1.3 accessData函数函数主体实现1. 计算最旧数据的位置逻辑起始点2. 计算两段连续内存长度函数作用避免堆分配不创建临时 QVector减少内存碎片CPU缓存友好数据直接在原内存位置被消费提高缓存命中率支持流式处理可在回调中直接写入文件、网络发送无需中转缓冲区实现缺陷总结2.1.4 setCapacity函数函数主体实现前言在工业监控、传感器数据采集、金融行情等场景中需要处理高频率100Hz的实时数据流同时保持 UI 的流畅性60fps。传统的追加数据-全量重绘模式会导致内存持续增长和界面卡顿。本文介绍一种基于环形缓冲区Ring Buffer的生产级解决方案。效果演示示例图中两条曲线是构造数据添加上去的一条是正弦波一条是方波正弦波设置了数据容量10000点方波设置了数据容量30000点数据刷新速率是30fps添加频率是1kHz这是没有启用动态时间轴的演示下面是使用动态时间轴的演示源码地址在文末。一、架构设计1.1 分层架构图1.2 数据写入流1.3 数据刷新流 (定时器驱动 → 视图更新)1.4 核心设计思想数据与视图分离PlotCurveDataModel只管理数据PlotCurveController负责同步到QCustomPlotPlotRefresher负责固定时长刷新视图固定内存占用RingBuffer预分配内存避免频繁的堆分配线程安全采用QReadWriteLock实现多读单写支持后台采集线程直接写入零拷贝优化提供accessData回调接口避免不必要的数据拷贝。二、核心实现详解2.1 RingBuffer环形缓冲区实现#include QVector #include QReadWriteLock class RingBuffer { public: explicit RingBuffer(int capacity 10000); void append(double key, double value); // 写 void copyTo(QVectordouble xs, QVectordouble ys) const; // 读 void accessData(std::functionvoid(const double*, const double*, int) callback) const; int size() const; int capacity() const; bool isFull() const; void setCapacity(int capacity); private: int bufferCapacity 10000; int bufferHead 0; // 写入位置 int bufferDataCount 0; // 当前元素数 QVectordouble _key; QVectordouble _value; mutable QReadWriteLock bufferLock; };//环形缓冲区实现 RingBuffer::RingBuffer(int capacity) : bufferCapacity(std::max(capacity, 10000)) { _key.resize(bufferCapacity); _value.resize(bufferCapacity); } //追加一点数据 void RingBuffer::append(double key, double value) { QWriteLocker locker(bufferLock); _key[bufferHead] key; _value[bufferHead] value; bufferHead (bufferHead 1) % bufferCapacity; if (bufferDataCount bufferCapacity) bufferDataCount; } //返回拷贝视图 void RingBuffer::copyTo(QVectordouble xs, QVectordouble ys) const { QReadLocker locker(bufferLock); xs.resize(bufferDataCount); ys.resize(bufferDataCount); for (int i 0; i bufferDataCount; i) { int idx (bufferHead - bufferDataCount i bufferCapacity) % bufferCapacity; xs[i] _key[idx]; ys[i] _value[idx]; } } //零拷贝数据访问 通过回调实现 void RingBuffer::accessData(std::functionvoid (const double *, const double *, int) callback) const { QReadLocker locker(bufferLock); // 处理环形缓冲区的非连续内存可能分两段 int firstSegment std::min(bufferDataCount, bufferCapacity - (bufferHead - bufferDataCount bufferCapacity) % bufferCapacity); int secondSegment bufferDataCount - firstSegment; int startIdx (bufferHead - bufferDataCount bufferCapacity) % bufferCapacity; if (secondSegment 0) { // 数据连续直接回调 callback(_key.constData() startIdx, _value.constData() startIdx, bufferDataCount); } else { // 数据分两段回调两次或合并到临时缓冲区 callback(_key.constData() startIdx, _value.constData() startIdx, firstSegment); callback(_key.constData(), _value.constData(), secondSegment); } } //获取当前数据量 int RingBuffer::size() const { QReadLocker locker(bufferLock); return bufferDataCount; } //获取当前缓冲区大小 int RingBuffer::capacity() const { QReadLocker locker(bufferLock); return bufferCapacity; } //判满 bool RingBuffer::isFull() const { QReadLocker locker(bufferLock); return (bufferDataCount bufferCapacity); } //重置容量 void RingBuffer::setCapacity(int capacity) { if (capacity 0 || capacity bufferCapacity) return; // 如果扩容且容量增幅不大考虑原地 reallocQVector 自动处理 if (capacity bufferCapacity) { QWriteLocker w(bufferLock); _key.resize(capacity); _value.resize(capacity); bufferCapacity capacity; return; } // 在单次写锁内完成所有操作 QWriteLocker w(bufferLock); // 如果新容量小于当前数据量只保留最新的 capacity 条 int newDataCount qMin(bufferDataCount, capacity); QVectordouble newKey(capacity); QVectordouble newValue(capacity); // 复制最新的 newDataCount 条数据 for (int i 0; i newDataCount; i) { int srcIdx (bufferHead - newDataCount i bufferCapacity) % bufferCapacity; newKey[i] _key[srcIdx]; newValue[i] _value[srcIdx]; } _key std::move(newKey); _value std::move(newValue); bufferHead newDataCount; bufferDataCount newDataCount; bufferCapacity capacity; }下面对关键实现以及细节进行详细探讨2.1.1 append函数函数主体实现void RingBuffer::append(double key, double value) { QWriteLocker locker(bufferLock); _key[bufferHead] key; _value[bufferHead] value; bufferHead (bufferHead 1) % bufferCapacity; if (bufferDataCount bufferCapacity) bufferDataCount; }第一行QWriteLocker locker(bufferLock)QWriteLocker locker(bufferLock);可以看到函数进入后使用QWriteLocker locker(bufferLock);实现线程安全屏障其作用有RAII机制构造时加写锁析构时函数退出自动解锁异常安全即使抛出异常也能解锁互斥策略阻止其他读线程QReadLocker和写线程访问保证原子性写入性能代价无竞争时约20-50ns高竞争时可能阻塞但比QMutex更适合多读场景第2-3行数组直接索引写入_key[bufferHead] key; _value[bufferHead] value;O(1)时间复杂度直接内存寻址无查找开销预分配优势_key和_value在构造函数中已通过resize预分配无堆分配这是与std::vector::push_back的本质区别缓存友好两次连续内存写入如果key和value数组相邻CPU缓存命中率高第4行环形回绕核心逻辑bufferHead (bufferHead 1) % bufferCapacity;模运算当bufferHead到达数组末尾capacity-1时(capacity-1 1) % capacity 0回到开头FIFO策略新数据永远覆盖最旧的数据bufferHead指向的位置无分支预测模运算比分支判断if (head capacity) head 0;更适合CPU流水线注意这里bufferHead始终指向下一个写入的位置也就是当回绕时其始终指向最老数据的位置第5行有效数据量管理if (bufferDataCount bufferCapacity) bufferDataCount;双状态区分未满状态bufferDataCount capacity缓冲区在填充阶段count表示实际数据量已满状态bufferDataCount capacity缓冲区满bufferDataCount保持恒定新数据覆盖旧数据读取边界告诉读线程应该读取多少个元素从bufferHead-bufferDataCount到bufferHead内存状态演变图解假设capacity 5写入序列[A, B, C, D, E, F, G]环形缓冲区内存示意表步骤写入bufferHeadbufferDataCount内存状态_key数组说明初始-00[_, _, _, _, _]空缓冲区1A11[A, _, _, _, _]正常填充2B22[A, B, _, _, _]正常填充3C33[A, B, C, _, _]正常填充4D44[A, B, C, D, _]正常填充5E05[A, B, C, D, E]首次回绕满状态6F15[F, B, C, D, E]覆盖A保留B-E7G25[F, G, C, D, E]覆盖B保留C-G关键当写入第6个元素F时bufferHead回到0覆盖了最旧的A但bufferDataCount保持5满容量。这样做的好处是内存大小占用恒定不会因为长时间运行导致内存爆炸卡死用过QCustomplot的读者都知道长时间绘图若没有限制数据点数量会导致内存不断膨胀卡死其中另外一种解决办法是删除最旧的数据点在QCustomplot库中手动添加一个removeDataBefore方法具体可见如下这篇博客http://【Qcustomplot内存超出问题 - CSDN App】https://blog.csdn.net/weixin_44802704/article/details/132401999?sharetypeblogshareId132401999sharereferAPPsharesourceh6030sharefromlinkhttp://【Qcustomplot内存超出问题 - CSDN App】https://blog.csdn.net/weixin_44802704/article/details/132401999?sharetypeblogshareId132401999sharereferAPPsharesourceh6030sharefromlink而本文的实现更适用于固定窗口大小的实时数据流但在高频场景1kHz下建议配合批量写入或双缓冲策略将锁竞争降到最小。2.1.2 copyTo函数函数主体实现void RingBuffer::copyTo(QVectordouble xs, QVectordouble ys) const { QReadLocker locker(bufferLock); xs.resize(bufferDataCount); ys.resize(bufferDataCount); for (int i 0; i bufferDataCount; i) { int idx (bufferHead - bufferDataCount i bufferCapacity) % bufferCapacity; xs[i] _key[idx]; ys[i] _value[idx]; } }这是环形缓冲区Circular Buffer/Ring Buffer最核心的数据导出函数。其功能是将环形缓冲区中的数据按时间顺序从旧到新拷贝到两个QVector中供绘图使用。第一行QReadLocker locker(bufferLock) -读写锁QReadLockerQReadLocker locker(bufferLock);允许多个读线程并发执行copyTo阻塞写线程append中的QWriteLocker函数中bufferLock声明为mutable符合逻辑常量性第二、三行resize预分配内存xs.resize(bufferDataCount); ys.resize(bufferDataCount);使用resize而非reserve确保operator[]安全且最终size()正确如果外部传入的xs/ys已有数据会被截断或扩展四 - 九行循环计算索引写入for (int i 0; i bufferDataCount; i) { int idx (bufferHead - bufferDataCount i bufferCapacity) % bufferCapacity; xs[i] _key[idx]; ys[i] _value[idx]; }模运算优化现代编译器会将% bufferCapacity优化为位运算当 capacity 为 2^n 时 bufferCapacity是关键技巧处理bufferHead bufferDataCount时的负数情况这里其实现的是物理上数据是循环存储的逻辑上线性输出按数据时间对照环形缓冲区内存示意表逐步推导一下索引计算公式int idx (bufferHead - bufferDataCount i bufferCapacity) % bufferCapacity部分含义bufferHead - bufferDataCount定位到最老数据的起始位置 i偏移到当前要读取的第 i 个元素 bufferCapacity防止负数确保模运算前为正% bufferCapacity处理回绕wrap-around示例演算承接上面的 F、B、C、D、E情况一缓冲区未满逻辑顺序旧→新A(0)、B(1)、C(2)、[ ]、[ ]数组[A, B, C, -, -]当前状态bufferHead 3,bufferDataCount 3,bufferCapacity 5计算i 0取A(3 - 3 0 5) % 5 (5) % 5 0同理得i 1取B,i2取C这里只能取到2因为for循环的条件是i bufferDataCount,即i 3情况二缓冲区刚好满逻辑顺序旧→新A(0)、B(1)、C(2)、D(3)、E(4)数组[A, B, C, D, E]当前状态bufferHead 0,bufferDataCount 5,bufferCapacity 5计算i 0取A(0 - 5 0 5) % 5 (0) % 5 0依次循环i 5取0 - 4即对应[A, B, C, D, E]逐个输出到xsys容器其就是已经按时间顺序排好了。情况三缓冲区满且回绕逻辑顺序旧→新C(2)、D(3)、E(4)、F(0)、G(1)数组[F, G, C, D, E]当前状态bufferHead 2,bufferDataCount 5,bufferCapacity 5计算i 0取C(2 - 5 0 5) % 5 (2) % 5 2计算i 2取E(2 - 5 2 5) % 5 (4) % 5 4当 i 2时实现索引回绕计算如 i 3此时 idx 0i 4此时 idx 1此时按照循环0 - 4idx索引输出为23401即对应最老数据 - 最新数据2.1.3 accessData函数函数主体实现void RingBuffer::accessData(std::functionvoid (const double *, const double *, int) callback) const { QReadLocker locker(bufferLock); // 处理环形缓冲区的非连续内存可能分两段 int firstSegment std::min(bufferDataCount, bufferCapacity - (bufferHead - bufferDataCount bufferCapacity) % bufferCapacity); int secondSegment bufferDataCount - firstSegment; int startIdx (bufferHead - bufferDataCount bufferCapacity) % bufferCapacity; if (secondSegment 0) { // 数据连续直接回调 callback(_key.constData() startIdx, _value.constData() startIdx, bufferDataCount); } else { // 数据分两段回调两次或合并到临时缓冲区 callback(_key.constData() startIdx, _value.constData() startIdx, firstSegment); callback(_key.constData(), _value.constData(), secondSegment); } }这是一个零拷贝Zero-Copy数据访问接口的实现核心目的是在不复制数据的前提下安全地读取环形缓冲区中可能物理不连续的内存数据通过Callback回调在一次锁内访问数据。其两次回调原因是环形缓冲区在逻辑上按时间是需要连续的[0→1→2→...→N]但物理内存可能断裂数据内容: d5 d6 d7 d8 d9 d10 d1 d2 d3 d4 物理索引: [0] [1] [2] [3] [4] [5] [6] [7] [8] [9] ↑head ↑start1. 计算最旧数据的位置逻辑起始点int startIdx (bufferHead - bufferDataCount bufferCapacity) % bufferCapacity;bufferHead下一个写入位置当前为空或即将被覆盖bufferHead - bufferDataCount回退 N 个元素到最旧数据 bufferCapacity防止负数保证取模正确% bufferCapacity回绕到合法索引2. 计算两段连续内存长度// 计算第一段连续内存的长度 int firstSegment std::min( bufferDataCount, // 总数据量上限 bufferCapacity - startIdx // 从startIdx到缓冲区末尾的空间 ); // 第二段长度回绕部分 int secondSegment bufferDataCount - firstSegment;如果startIdx7, capacity10则剩余空间3索引7,8,9如果数据量6 3则第一段只能取3个剩下3个在索引0,1,2第二段函数作用避免堆分配不创建临时QVector减少内存碎片CPU缓存友好数据直接在原内存位置被消费提高缓存命中率支持流式处理可在回调中直接写入文件、网络发送无需中转缓冲区实现缺陷如果callback中执行了任何耗时操作绘图、文件IO、网络请求、甚至QThread::msleep整个写入线程会被阻塞所以回调中应该只做最轻量的操作如指针转发、快速拷贝到线程本地存储改进可考虑双缓冲Double Buffering维护两个缓冲区交替使用写入时写到后台缓冲区完成后原子交换指针。读取总是看到物理连续的前台缓冲区。其读取时无需锁原子指针交换物理连续真正Zero-Copy但是其缺点是内存占用翻倍写入时有memmove开销滑动窗口时。或者使用虚拟内存魔术Linux高级技巧利用mmap创建环形虚拟地址映射让同一物理页映射到虚拟地址空间的两个位置实现物理断裂但虚拟连续。优点是单次回调Zero-Copy无内存拷贝缺点平台依赖Linux onlyWindows实现极难调试困难页对齐浪费内存。总结accessData是一个面向高性能场景的接口其解决了环形缓冲区的物理不连续性问题通过分段回调实现逻辑连续性的抽象。但需要保证以下使用铁律回调中严禁耗时操作IO、复杂计算、UI更新回调中严禁再次调用 RingBuffer 的任何方法死锁风险如果数据需要跨线程使用必须拷贝copyTo更安全由于下游是QCustomPlot其setData函数要求QVectordouble连续内存。两段式回调对QCustomPlot并不友好所以此处这个函数并没有使用到。在追求极致性能情况下应放弃setData改用QCPGraph::data()获取内部指针直接写入这个感兴趣的读者可以自行研究。2.1.4 setCapacity函数函数主体实现void RingBuffer::setCapacity(int capacity) { if (capacity 0 || capacity bufferCapacity) return; // 如果扩容且容量增幅不大考虑原地 reallocQVector 自动处理 if (capacity bufferCapacity) { QWriteLocker w(bufferLock); _key.resize(capacity); _value.resize(capacity); bufferCapacity capacity; return; } // 在单次写锁内完成所有操作 QWriteLocker w(bufferLock); // 如果新容量小于当前数据量只保留最新的 capacity 条 int newDataCount qMin(bufferDataCount, capacity); QVectordouble newKey(capacity); QVectordouble newValue(capacity); // 复制最新的 newDataCount 条数据 for (int i 0; i newDataCount; i) { int srcIdx (bufferHead - newDataCount i bufferCapacity) % bufferCapacity; newKey[i] _key[srcIdx]; newValue[i] _value[srcIdx]; } _key std::move(newKey); _value std::move(newValue); bufferHead newDataCount; bufferDataCount newDataCount; bufferCapacity capacity; }这是一个线程安全的动态容量调整实现针对环形缓冲区的物理非连续性特性做了特殊处理。该函数实现了缩容整理、扩容原地的策略确保在任何情况下数据逻辑顺序都不丢失。1. 函数整体策略场景策略数据整理时间复杂度扩容(capacity old)原地 realloc不整理依赖QVector::resizeO(old) 或 O(1)缩容(capacity old)重建复制强制整理为物理连续丢弃旧数据O(min(old, new))2. 参数检查if (capacity 0 || capacity bufferCapacity) return;拒绝无效容量0如果容量没变化直接返回3. 扩容分支if (capacity bufferCapacity) { QWriteLocker w(bufferLock); _key.resize(capacity); _value.resize(capacity); bufferCapacity capacity; return; }为什么这么简单当bufferHead bufferDataCount数据已回绕时数据物理分布可能是索引: 0 1 2 ... head ... capacity-1 数据: d5 d6 -- d1 d2 d3 d4 ↑新数据 ↑旧数据将被覆盖QVector::resize()在扩容时如果原地有足够空间直接扩展尾部realloc可能移动整内存块但保持已有数据相对位置无论是否移动内存已有元素的顺序和值不变因此扩容后回绕状态依然保持head 和 count 无需修改新空间自然成为尾部缓冲区。风险如果频繁在满回绕状态下小幅扩容每次resize都可能触发全量memcpy将整个缓冲区复制到新内存块。4. 缩容分支// 只保留最新的 capacity 条数据 int newDataCount qMin(bufferDataCount, capacity); QVectordouble newKey(capacity); QVectordouble newValue(capacity); // 复制最新的 newDataCount 条数据 for (int i 0; i newDataCount; i) { int srcIdx (bufferHead - newDataCount i bufferCapacity) % bufferBuffer; newKey[i] _key[srcIdx]; newValue[i] _value[srcIdx]; }索引计算逻辑假设bufferHead3, bufferDataCount10, bufferCapacity10数据已回绕最老数据索引(3 - 10 10) % 10 3d4物理内存布局索引: 0 1 2 3 4 5 6 7 8 9 数据: d11 d12 d13 d4 d5 d6 d7 d8 d9 d10 ↑head3 ↑start5现在要缩容到capacity 6保留最新的 6 条d8, d9, d10, d11, d12, d13newDataCount min(10, 6) 6循环 i 0 ~ 5i 0:srcIdx (3 - 6 0 10) % 10 7→ d8最老i 1:srcIdx (3 - 6 1 10) % 10 8→ d9i 2:srcIdx (3 - 6 2 10) % 10 9→ d10i 3:srcIdx (3 - 6 3 10) % 10 0→ d11i 4:srcIdx (3 - 6 4 10) % 10 1→ d12i 4:srcIdx (3 - 6 5 10) % 10 2→ d13最新新缓冲区内存布局newKey: [d8, d9, d10, d11, d12, d13] 索引: 0 1 2 3 4 5结果数据从物理断裂回绕整理为物理连续且bufferHead newDataCount 6指向末尾准备下次写入。5. 内存优化_key std::move(newKey); _value std::move(newValue);为什么用std::move避免二次拷贝如果不使用moveQVector的赋值操作会触发深拷贝复制所有 double 元素指针转移std::move后newKey的内部指针直接转移给_key原newKey变为空旧内存释放原_key旧的环形缓冲区内存在赋值语句结束后自动析构释放等价于_key.swap(newKey); // Qt 容器也支持 swap效果类似6. 状态重置bufferHead newDataCount; bufferDataCount newDataCount; bufferCapacity capacity;为什么bufferHead newDataCount缩容后数据被整理为从索引 0 开始连续存储到newDataCount - 1此时bufferHead指向newDataCount即最后一个有效数据之后的位置如果newDataCount capacity缩容后刚好填满head指向缓冲区末尾下次写入会自然回绕到 0bufferDataCount正确反映当前数据量这保持了环形缓冲器的不变量bufferDataCount表示[head-count, head)区间内的有效数据量。总结该函数可实现动态容量调整适用于实时波形显示中动态调整 时间窗长度如从显示 10秒改为显示 5秒数据能平滑处理而不丢失最新采样点。三、源码地址Gitee码云https://gitee.com/mishm/qcustomplot-circular-bufferGithubhttps://github.com/H0138mw/-QCustomplot_Circle_Buffer
本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.coloradmin.cn/o/2467195.html
如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈,一经查实,立即删除!