ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

Qt多线程跨线程通信与环形缓冲区实战避坑

Qt多线程跨线程通信与环形缓冲区实战避坑

简介:面向Qt5与Visual Studio 2017环境下学习多线程开发的中级开发者,这份工程源码围绕主线程与子线程的数据交互展开,覆盖QThread子类化、moveToThread对象迁移、跨线程信号槽、GUI线程安全以及QMutex、QWaitCondition同步等核心问题,可作为理解Qt线程模型的完整对照案例。压缩包共87个文件、约110.4MB,以10个cpp源文件、6个头文件、2个ui界面文件为主,并包含sln、vcxproj、pro、qrc等工程配置及编译中间文件和日志,便于直接在VS中打开调试。已有2173人学习下载,适合需要从可运行代码中梳理线程创建、对象迁移、信号槽连接与安全退出流程的读者。工程还涉及QSharedPointer等内存管理策略与主线程刷新界面的协作方式,能帮助读者把线程同步、竞态规避和界面响应性落实到具体代码结构中,并理解两种常见Qt多线程写法的取舍。

1. 从一次界面卡死说起:QT 多线程到底解决什么问题

曲线每秒刷新 50 帧,串口一读就是几百字节,按钮点下去两秒才有反应——这是我第一次把采集逻辑放进 QT 多线程时的现场。后来才看明白,QT 多线程真正的难点不是“把活儿扔进子线程”,而是主线程与子线程交互数据这条通道:信号槽怎么连、自定义结构体能不能排进队列、共享缓冲区加不加锁、退出时谁等谁。这份示例工程把这些环节拆成能跑、能改的模块,适合刚接手 Qt 桌面项目、准备把耗时任务从 UI 线程挪出去的开发者,也适合已经写过 QThread、但被随机崩溃和关不掉的进程折磨过的人。下面按“先定架构、再打通数据、最后填坑”的顺序拆。

2. 线程模型选型:QThread 三种写法与工程目录怎么落地

动手写代码之前,有件事必须先定下来:子线程负责什么、主线程负责什么、两者之间的数据用哪种方式过河。这一步定错了,后面写多少代码都是在补窟窿。

2.1 先把职责切开:生产者、缓冲区、消费者

我一般把这类采集型应用切成三段。生产者是采集线程,只管从数据源读取并打包成一帧;缓冲区是中间层,负责削峰填谷,防止采样率和界面刷新率不匹配;消费者是 GUI 线程,只管把数据画出来。三段之间是单向流动,任何一段都不直接调用另一段的函数。

这样的结构带来的好处是,采样频率从 20Hz 提到 200Hz 时,界面不会跟着崩,因为缓冲区会把多余的数据挡住或者丢弃。反过来界面卡一下,也不会把采集线程拖死。代价是多了一层拷贝和一次加锁,但相对一次串口读写的开销,这点成本可以忽略。

示例工程的骨架大概长这样:

threadlab/ ├── CMakeLists.txt ├── src/ │ ├── main.cpp 应用入口,注册元类型 │ ├── ui/ │ │ ├── mainwindow.h │ │ └── mainwindow.cpp 只做渲染,不碰数据源 │ ├── worker/ │ │ ├── samplerworker.h/.cpp 采集任务对象(moveToThread 的主角) │ │ └── samplercontroller.h/.cpp 线程生命周期管理 │ ├── core/ │ │ ├── sampleringbuffer.h/.cpp QMutex + QWaitCondition 缓冲 │ │ ├── samplesource.h/.cpp 抽象数据源,方便换串口/仿真 │ │ └── sampletypes.h Q_DECLARE_METATYPE 定义 │ └── common/ │ └── threadlog.h 带线程 ID 的日志宏 └── tests/ └── tst_ringbuffer.cpp 只测缓冲,不测 UI

把 worker 和 controller 拆开是有讲究的。worker 是“干活的对象”,它会被 moveToThread 搬到子线程去;controller 是“管线程的对象”,它始终留在主线程,负责 start、stop、超时兜底。两者混成一个类,就会出现“这个槽到底跑在哪个线程”的玄学问题。

2.2 三种 QThread 写法,选错就等着查线程归属

Qt 里创建线程的路子有三条,用起来差别很大。

第一种是继承 QThread 并重写 run()。这个写法最直观,但坑也最多:QThread 对象本身属于创建它的线程(通常是主线程),只有 run() 内部才是新线程。于是很多人在构造函数里 new 出来的成员对象,它们的槽函数依然跑在主线程,跟以为的完全不一致。

第二种是 moveToThread。写一个普通 QObject 子类做 worker,把耗时逻辑放进槽函数,然后worker->moveToThread(&thread),靠QThread::started信号触发第一个槽。所有槽都在子线程里执行,事件循环正常运转,定时器、队列信号都能用。

第三种是 QThreadPool 配 QRunnable,或者 QtConcurrent::run。适合“一次性任务”,比如导出报表、批量转码,任务之间没有状态依赖,用完即走,不需要常驻线程。

三种写法的对比如下:

写法槽执行线程适合场景主要风险
继承 QThread 重写 run()run() 内是本线程,其它槽仍在原线程纯计算、无事件循环需求线程归属混淆,事件循环缺失
moveToThreadworker 的所有槽都在子线程常驻任务、需要定时器和队列信号move 时对象不能有 parent
QThreadPool / QtConcurrent线程池内部一次性批处理任务不适合长期持有状态

示例工程选的是第二种,因为采集任务需要定时触发、需要能被随时叫停,正好依赖事件循环。

2.3 工程配置与一次跑通的命令

构建脚本用 CMake,Qt5 和 Qt6 都能编。关键点是CMAKE_AUTOMOC ON,只要类里有Q_OBJECT,就必须开,否则信号槽链接期报 undefined reference,这类错误新手很容易卡半天。

cmake_minimum_required(VERSION 3.16) project(threadlab LANGUAGES CXX) set(CMAKE_CXX_STANDARD 17) set(CMAKE_CXX_STANDARD_REQUIRED ON) set(CMAKE_AUTOMOC ON) # 有 Q_OBJECT 必须开 set(CMAKE_AUTOUIC ON) set(CMAKE_AUTORCC ON) find_package(QT NAMES Qt6 Qt5 REQUIRED COMPONENTS Core Widgets) find_package(Qt${QT_VERSION_MAJOR} REQUIRED COMPONENTS Core Widgets) add_executable(threadlab src/main.cpp src/ui/mainwindow.cpp src/worker/samplerworker.cpp src/worker/samplercontroller.cpp src/core/sampleringbuffer.cpp src/core/samplesource.cpp ) target_include_directories(threadlab PRIVATE src) target_link_libraries(threadlab PRIVATE Qt${QT_VERSION_MAJOR}::Core Qt${QT_VERSION_MAJOR}::Widgets )

命令行三步走,Debug 构建方便挂调试器看线程栈:

cmake -S . -B build -DCMAKE_BUILD_TYPE=Debug cmake --build build -j ./build/threadlab

跑起来之后,窗口上会有一个开始/停止按钮和一条实时曲线。点开始,采集线程按 50ms 周期产出数据块;点停止,线程在 200ms 内退出,进程不留残余。这个“能干净退出”看起来不起眼,但它是判断线程模型有没有搭对的第一道门槛。

3. 主线程与子线程交互数据:信号槽跨线程投递与自定义类型

线程跑起来了,接下来是重点:数据怎么从子线程安全地送到主线程。Qt 给的答案是信号槽,但信号槽的连接类型、参数拷贝语义、元类型注册,每一处都能让人翻车。

3.1 AutoConnection 的真相:跨线程自动变队列

connect的第五个参数默认是Qt::AutoConnection,它的行为是运行时判断的:发射信号的线程和接收者对象所属线程相同,就走直连,槽函数在发射线程同步执行;不同,就自动降级成队列连接,信号参数被拷贝进接收线程的事件队列,等那边的事件循环取出来再调用。

这意味着两件事。一是你不能假设某个槽一定同步执行,写代码时如果依赖“emit 完就已经处理好了”,在跨线程场景下必然出错。二是参数必须可拷贝,指针和引用在队列连接里传过去的是拷贝值,如果参数里塞了裸指针,接收端拿到的是同一个地址,而那一刻发射端可能已经把它释放了。

四种连接类型的行为整理成表更清楚:

连接类型槽函数执行线程同步性使用注意
AutoConnection同线程直连,跨线程队列视情况默认值,别当直连用
DirectConnection发射者所在线程同步跨线程操作 UI 直接崩
QueuedConnection接收者所属线程异步参数需可拷贝、需注册元类型
BlockingQueuedConnection接收者所属线程同步阻塞发射者同一线程两端使用会死锁

示例工程里 worker 发数据给主窗口,用的就是默认的 AutoConnection,因为两端跨线程,实际生效的是队列连接。

3.2 定义可投递的数据块并注册元类型

第一段代码是数据类型本身。它必须满足三个条件:可默认构造、可拷贝、被元对象系统认识。

// src/core/sampletypes.h #pragma once #include <QMetaType> #include <QString> #include <QVector> // 子线程向主线程投递的一帧数据,必须是可拷贝的值类型 struct SampleBlock { quint64 seq = 0; // 帧序号,接收端靠它判断有没有丢帧 qint64 timestampMs = 0; // 采集时刻,毫秒时间戳 QVector<double> values; // 本帧采样值 QString source; // 通道名,多设备时用于区分 }; // 告诉 moc 这个类型可以进 QVariant、可以排队 Q_DECLARE_METATYPE(SampleBlock)

Q_DECLARE_METATYPE只是让编译器知道类型存在,真正让队列连接工作的是运行时的qRegisterMetaType。Qt5 里没注册就会在运行时报 “Cannot queue arguments of type 'SampleBlock'”,信号发出去但槽永远不触发,属于典型的黑匣子问题。Qt6 对部分场景做了自动注册,但显式写一行不会有任何坏处。

// src/main.cpp #include <QApplication> #include "ui/mainwindow.h" #include "core/sampletypes.h" int main(int argc, char *argv[]) { QApplication app(argc, argv); // 必须在任何跨线程信号发射之前完成注册 qRegisterMetaType<SampleBlock>("SampleBlock"); qRegisterMetaType<SampleBlock>("SampleBlock&"); MainWindow w; w.show(); return app.exec(); }

两个注册名都写,是因为信号参数用const SampleBlock&时,某些 Qt5 版本按带引号的字符串查找元类型。参数说明:模板参数是 C++ 类型,函数参数是队列连接里用来查表的字符串名,两者最好保持一致。

3.3 Worker 的信号与主线程的接收槽

worker 端只做两件事:周期采一帧,然后 emit 出去。注意它是被 moveToThread 搬到子线程的普通 QObject。

// src/worker/samplerworker.h #pragma once #include <QObject> #include <QTimer> #include "core/sampletypes.h" class SampleSource; class SamplerWorker : public QObject { Q_OBJECT public: explicit SamplerWorker(SampleSource *source, QObject *parent = nullptr); ~SamplerWorker() override; public slots: void start(); // 由 QThread::started 触发,运行在子线程 void stop(); // 叫停采集并退出事件循环 void setIntervalMs(int ms); // 运行期可调采样周期 signals: void blockReady(const SampleBlock &block); // 跨线程投递 void failed(const QString &reason); void finished(); private slots: void doOneCycle(); // 由定时器驱动,保持事件循环空转 private: SampleSource *m_source = nullptr; QTimer *m_timer = nullptr; // 在子线程里创建,归属子线程 quint64 m_seq = 0; };

文件里没有 QThread 成员,线程的持有权交给 controller。这种“干活的不管线程”的分法,后面改线程池也好、改多实例也好,改动面都很小。

实现部分,采集循环用 QTimer 驱动,而不是 while 加 sleep。

// src/worker/samplerworker.cpp #include "samplerworker.h" #include "core/samplesource.h" #include <QDateTime> #include <QThread> SamplerWorker::SamplerWorker(SampleSource *source, QObject *parent) : QObject(parent), m_source(source) { } SamplerWorker::~SamplerWorker() = default; void SamplerWorker::start() { // 定时器在哪个线程创建就归属哪个线程,这里在子线程内,所以安全 if (!m_timer) { m_timer = new QTimer(this); connect(m_timer, &QTimer::timeout, this, &SamplerWorker::doOneCycle); } m_timer->start(50); // 20Hz,可根据设备实际速率调整 } void SamplerWorker::doOneCycle() { SampleBlock block; if (!m_source->read(block, 10)) { // 10ms 读超时,避免卡住定时器 emit failed(QStringLiteral("read timeout")); return; } block.seq = ++m_seq; block.timestampMs = QDateTime::currentMSecsSinceEpoch(); emit blockReady(block); // 队列连接:这里只是把拷贝塞进主线程队列 } void SamplerWorker::setIntervalMs(int ms) { if (m_timer) m_timer->setInterval(qMax(1, ms)); } void SamplerWorker::stop() { if (m_timer) m_timer->stop(); emit finished(); // 同一线程内调用 quit,直接请求事件循环退出;跨线程排队调用容易在等待时死锁 QThread::currentThread()->quit(); }

doOneCycle里 emit 之后立刻返回,不做任何等待,这是队列连接的正确用法。定时器保持在子线程的事件循环里跑,好处是槽函数随时能被调用,stop 请求发过来能马上响应。如果换成while (true) { ... QThread::msleep(50); },事件循环根本没在转,排进队列的 stop 永远执行不到,程序关不掉。

主窗口这边接收槽只做渲染和统计,不碰数据源:

// src/ui/mainwindow.cpp 片段 void MainWindow::onBlockReady(const SampleBlock &block) { // 执行到这里时已经在 GUI 线程,可以放心操作控件 if (!block.values.isEmpty()) m_series->append(block.timestampMs, block.values.last()); if (m_lastSeq != 0 && block.seq != m_lastSeq + 1) ++m_droppedCount; // 丢帧统计,比猜有用得多 m_lastSeq = block.seq; }

参数用const SampleBlock&,队列连接内部会完整拷贝一份到事件对象里,接收端拿到的是独立副本,不存在悬垂问题。但如果同一线程用直连传引用,而接收方把引用存成成员变量,函数返回后就会访问已析构的对象,这类问题在单线程下不明显,跨线程一测就现形。

4. 共享缓冲区:QMutex 配 QWaitCondition 的生产消费实现

信号槽解决的是“投递”,但遇到高频数据就不够用了:一帧一个信号,200Hz 就是每秒两百次事件投递加上两百次内存分配,主线程的事件队列会被塞满,界面反而更卡。这种时候需要一个共享缓冲区,把多发少收的节奏抹平。

4.1 环形缓冲区为什么要两个条件变量

缓冲区的角色是“定量容器的中转站”。生产者往尾部写,消费者从头部读,容量满了生产者等,容量空了消费者等。用一个 QMutex 保护下标和计数,再用两个 QWaitCondition 分别通知“非空”和“非满”,这就是标准的生产者消费者模型。

只用一个条件变量也能跑,但会带来无谓的唤醒:消费者刚取走一条数据,唤醒的还是消费者,生产者依然在睡。两个条件变量各管一边,唤醒精准,在高频场景下差别明显。

还有几个细节容易忽略。等待必须放在while循环里而不是if,因为条件变量存在虚假唤醒,被唤醒不代表条件一定满足。等待要带超时,万一哪一方因为逻辑 bug 再也不会通知,超时至少能让人从死锁里脱身。关闭标志也必须有,程序退出时得把所有等待者叫醒,否则线程永远卡在 wait 上。

4.2 缓冲区的完整实现

// src/core/sampleringbuffer.h #pragma once #include <QMutex> #include <QWaitCondition> #include <QVector> #include "sampletypes.h" class SampleRingBuffer { public: explicit SampleRingBuffer(int capacity = 2048); // 满则等待,超时返回 false;closed 后立即返回 false bool push(const SampleBlock &block, int timeoutMs = 200); // 空则等待,超时返回 false bool pop(SampleBlock &out, int timeoutMs = 200); void close(); // 唤醒所有等待者,让生产消费双方都能退出 int size() const; private: mutable QMutex m_mutex; QWaitCondition m_notEmpty; QWaitCondition m_notFull; QVector<SampleBlock> m_buf; int m_head = 0; // 读位置 int m_tail = 0; // 写位置 int m_count = 0; // 当前元素数 bool m_closed = false; };
// src/core/sampleringbuffer.cpp #include "sampleringbuffer.h" SampleRingBuffer::SampleRingBuffer(int capacity) : m_buf(capacity) { } bool SampleRingBuffer::push(const SampleBlock &block, int timeoutMs) { QMutexLocker locker(&m_mutex); // while 而非 if:条件变量可能虚假唤醒 while (m_count == m_buf.size() && !m_closed) { if (!m_notFull.wait(&m_mutex, timeoutMs)) return false; // 超时返回,交给调用方决定丢弃还是重试 } if (m_closed) return false; m_buf[m_tail] = block; m_tail = (m_tail + 1) % m_buf.size(); ++m_count; m_notEmpty.wakeOne(); // 只叫醒一个消费者 return true; } bool SampleRingBuffer::pop(SampleBlock &out, int timeoutMs) { QMutexLocker locker(&m_mutex); while (m_count == 0 && !m_closed) { if (!m_notEmpty.wait(&m_mutex, timeoutMs)) return false; } if (m_count == 0) return false; // closed 且已空 out = m_buf[m_head]; m_head = (m_head + 1) % m_buf.size(); --m_count; m_notFull.wakeOne(); return true; } void SampleRingBuffer::close() { QMutexLocker locker(&m_mutex); m_closed = true; m_notEmpty.wakeAll(); // 两个条件变量都要叫醒 m_notFull.wakeAll(); }

QMutexLocker是这里的保命符,构造加锁、析构解锁,函数中间 return 也不会漏解锁。参数timeoutMs控制最长等待时间,采集端一般给 100~200ms,太长会让停止响应变慢。容量 2048 是按“缓冲区能撑住 1 秒的采样量”来定的,实际项目里按采样率乘容忍延迟估一下就行,不用追求精确。

消费者端不需要单独的线程时,可以直接在主线程用 QTimer 批量取,一次取走当前可用的全部数据再统一刷新曲线。这样事件循环的压力从“每帧一次”降到“每 30ms 一次”,界面明显顺滑。

4.3 锁的粒度与替代方案怎么选

有人会问,能不能不用锁。答案是可以,但要看你愿不愿意付复杂度。无锁环形队列需要原子下标和内存序,写对不容易,调试更麻烦,而且在这个场景里大概率省不下多少时间——瓶颈通常在串口读写和曲线渲染上,不在那次几十纳秒的加锁。

QMutex 默认是非递归的,同一线程重复加锁会直接死锁,这点务必记住。如果确实存在读多写少的情况,可以换 QReadWriteLock,读锁并发、写锁独占。但在元素只有几个字节、临界区只有几行的场景里,读写锁的额外开销可能比互斥锁还大,别过早优化。

还有一个替代品是 QSemaphore:用两个信号量分别表示空位和已有元素,实现更简洁。代价是它不支持条件变量的超时等待语义那么灵活,关闭时的唤醒也要多写几行。示例工程选 QMutex 加 QWaitCondition,是因为它把“等待超时”和“关闭唤醒”两件事表达得最直白。

5. 避坑与排查:五个让我加班的跨线程问题

下面这些不是从文档抄的,是调试器里一行行看出来的。每条按现象、原因、解决写,遇到类似症状可以直接对号入座。

5.1 界面与线程归属类问题

第一条:子线程里直接改控件,程序偶发崩溃。

现象:点开始后界面正常刷新,跑十几分钟后进程无提示退出,日志停在某次 setText 之前。

原因:QWidget 及其子类不是线程安全的,所有绘制和属性修改只能在 GUI 线程做。子线程直接调用时,底层资源竞争,崩不崩取决于运气,这类玄学问题最难查。

解决:所有跨线程的界面更新一律走信号槽或QMetaObject::invokeMethod(obj, "slotName", Qt::QueuedConnection)。如果只是刷新频率太高想省事,可以在子线程里算好数据,主线程定时取,绝不越过线程边界碰控件。

第二条:moveToThread 之后,槽函数还是跑在主线程。

现象:打印线程 ID 发现 worker 的槽和主窗口是同一个线程 ID,耗时逻辑照样卡界面。

原因:moveToThread 只能搬走对象本身,搬不走它的子对象。如果 worker 在构造时带了个 parent,或者成员对象是在主线程 new 出来且没有归属到 worker,它们仍然属于主线程。另外,在 moveToThread 之后又给对象 setParent,会直接失败并打印警告。

解决:worker 构造时 parent 传 nullptr,moveToThread 之后再建立连接;worker 内部要用的 QObject 成员,在 start() 槽里创建,让它们归属子线程。排查时在关键槽里打一行qDebug() << QThread::currentThread(),一眼就能看出归属对不对。

5.2 退出与生命周期类问题

第三条:关闭窗口后进程残留,任务管理器里还在。

现象:主窗口关了,进程没退,调试器里能看到子线程还挂在某个 wait 上。

原因:两种常见情况。一是采集循环写成 while 加 sleep,事件循环没在转,排进队列的 stop 永远执行不到。二是主线程调用thread.wait()阻塞了自己,而退出请求是靠队列连接投递回主线程处理的,两边互相等,典型的死锁。

解决:循环改由 QTimer 驱动,保证事件循环始终运转;退出请求在子线程内部直接调用QThread::currentThread()->quit(),不经过主线程中转;主线程 wait 一定要带超时,超时后 requestInterruption 再兜底。这三板斧下去,退出问题基本绝迹。

第四条:删 worker 的时候崩溃,或者报跨线程创建子对象的警告。

现象:程序退出瞬间崩溃,调用栈停在 delete 上;或者日志里出现 “Cannot create children for a parent that is in a different thread”。

原因:对象在 A 线程创建,却在 B 线程 delete,析构里如果还有子对象要处理,就会触发跨线程操作。跨线程直接 delete 是红线。

解决:用connect(&thread, &QThread::finished, worker, &QObject::deleteLater),让对象在自己的线程里销毁。注意 deleteLater 依赖事件循环,所以顺序必须是先 quit 再等 finished,不能反过来。

5.3 排查手段:让线程问题不再是黑匣子

第五条:信号发出去了,槽没反应,也不报错。

现象:emit 之后接收端毫无动静,断点根本不进。

原因:三种可能。参数类型没注册元类型,队列连接在运行时报错但常被日志级别过滤掉;连接时接收者对象已经被销毁,连接自动失效;或者信号签名和槽签名不匹配,比如一个有 const 引用一个没有,编译期不报错但运行期匹配失败。

解决:连接前先检查connect的返回值,返回 false 就打日志;参数统一用const T&并在 main 里注册;调试期把QT_LOGGING_RULES里 qt.core 的调试输出打开,队列连接失败会有明确提示。

至于排查工具,我习惯在日志宏里带上线程 ID 和毫秒时间戳:

// src/common/threadlog.h #pragma once #include <QDebug> #include <QThread> #include <QDateTime> #define TLOG() qDebug() << QDateTime::currentMSecsSinceEpoch() \ << QThread::currentThread()

一行日志就能看出这个函数到底跑在哪个线程,比在调试器里翻线程列表快得多。配合 QElapsedTimer 打点,还能顺带看出缓冲区是不是在频繁超时。

6. 二次开发:把示例骨架换成你自己的任务框架

这套骨架真正值钱的地方在于边界划得清楚,换成你自己的业务时,改动集中在三处。

第一处是数据源。把SampleSource抽成纯虚接口,串口、网口、仿真各写一个实现,worker 一行都不用改。第二处是数据处理。如果计算量不大,直接在主线程的接收槽里算;如果单帧计算超过几毫秒,就在缓冲区和主线程之间再插一个计算线程,让它也走同一套 moveToThread 加信号槽的模式。第三处是投递策略。高频场景下别一帧一个信号,改成主线程定时批量取,或者只保留最新值合并投递:

改造点改动位置适用场景注意事项
换数据源新增 SampleSource 子类串口换网口、接仿真器保持 read 的超时语义一致
加计算线程新增一个 worker 类单帧计算超过 5ms缓冲区容量要跟着放大
批量投递主线程定时器 + pop 循环采样率高于 100Hz注意关闭时先停定时器
多设备并行每个设备一个 controller 实例多通道同步采集时间戳统一用同一时钟源

我最常做的一个改造是“最新值合并”:主线程每 30ms 取一次缓冲区,把取到的所有帧合并成一段再刷新曲线,中间的帧只更新统计量。代码很短,但对界面流畅度的提升比调任何参数都明显。

// 主线程定时批量取,避免事件队列被高频信号塞满 void MainWindow::drainBuffer() { SampleBlock block; QVector<double> batchX, batchY; while (m_buffer->pop(block, 0)) { // 超时传 0,取空为止 batchX.append(block.timestampMs / 1000.0); batchY.append(block.values.last()); } if (!batchX.isEmpty()) m_series->replace(batchX, batchY); // 一次性替换,减少重绘次数 }

验证改造是否成功有个笨办法但很有效:把采样周期调到 5ms 跑十分钟,同时开着任务管理器看内存和 CPU 曲线,再点几次开始停止。如果内存不涨、CPU 平稳、每次都能干净退出,说明线程模型是站得住的;只要有一项异常,就先回去查缓冲区关闭和 deleteLater 的顺序。从那以后我每次加新线程,都强制先写一个“能反复启停一百次不崩”的最小用例,再往上堆业务逻辑。希望帮到你。

本文还有配套的精品资源,点击获取

返回列表