尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

Qt多线程实战:主线程与子线程数据交互与界面卡顿解决方案

Qt多线程实战:主线程与子线程数据交互与界面卡顿解决方案 简介面向需要在Visual Studio 2017中落地QT多线程开发的实践型资源聚焦主线程与子线程如何安全交互数据系统覆盖QThread创建、run()重写、moveToThread()对象迁移、信号槽跨线程通信等核心机制。压缩包共87个文件包含完整可编译的VS/QT工程源码cpp/h头文件与实现、ui界面定义、qrc资源文件、sln/vcxproj工程配置以及编译过程生成的obj/tlog等文件整体约110.4MB。示例工程中包含多个可运行演示模块可直接观察WorkerObject对象迁移到子线程、finished信号驱动线程退出的典型写法同时涉及QMutex同步、主线程专属GUI更新等关键设计原则。已有2171人学习下载适合初学者结合代码理解线程模型也适合需要将工程迁移至QT5VS2017环境、参考异步架构的开发者进行二次开发。 搞 Qt 开发的朋友十有八九都遇到过这种画面界面上一个按钮点下去整个窗口直接变成“白屏未响应”鼠标转圈转到天荒地老标题栏还给你补一刀“无响应”。等个三五秒程序回过神来了界面才把刚才的点击反应完。这一年又一年不少项目就是这么从“能用就行”被用户骂到“必须重构”的。这个问题的根源多半就是你把耗时操作直接丢在主线程里跑了。主线程在 Qt 里也叫 GUI 线程它既要处理界面绘制、鼠标键盘事件又要跑你的业务逻辑一旦忙不过来界面就卡死给你看。要解决它就得靠 Qt 的多线程编程把耗时任务丢到子线程去等子线程处理完了再把结果“安全地”交回主线程更新界面。这篇文章我就围绕 Qt 多线程里最核心的“主线程与子线程数据交互”这件事把设计思路、代码写法、坑位和排查技巧一次讲透。适合刚接触 Qt 线程、被界面卡顿折磨、或者在跨线程传数据时频繁踩坑的朋友。1. 先捋清楚主线程和子线程各管哪摊事1.1 主线程不是“主”在权限上而是“主”在职责上很多初学者有个误解觉得主线程就是程序最先跑起来的那个线程地位高一点。其实在 Qt 里主线程真正的特殊之处在于它是唯一被允许创建和操作QWidget、QPainter、QPixmap等 GUI 对象的线程。换句话说所有界面控件只能在主线程里碰你在子线程里直接调用label-setText(hello)大部分时候不会立刻崩溃但会随机地在你意想不到的地方崩给你看或者是画出来的界面花掉、刷新错乱。这个限制不是 Qt 闲着没事加上去的而是因为 GUI 框架内部的大量状态不是一个线程安全的集合。你想象一下主线程正在绘制一个按钮的背景图子线程突然把按钮的文字改了那这块内存到底该按哪个版本渲染两边同时读写数据就乱了。所以 Qt 把规矩定得很死谁创建的控件谁才有资格去改它。1.2 子线程能干什么不能碰什么子线程是干苦力活的。文件读取、数据库查询、HTTP 请求、串口读写、大数据量计算、时域数据转频域的 FFT 运算……这些动辄几十毫秒到几秒钟的任务全都应该扔到子线程。做完之后子线程把结果打包好再发信号通知主线程来更新 UI。但是子线程有绝对不能碰的东西所有QWidget及其子类对象包括 QLabel、QPushButton、QProgressBar 这些QPixmap、QImage这类和绘图设备相关的对象虽然 QImage 在部分情况下可以跨线程但新手阶段我建议一律只在主线程里碰还有QApplication对象本身和所有事件循环相关的东西子线程有自己的事件循环可以跑但绝不能去操作 GUI 线程的事件循环。那子线程和主线程之间怎么传数据答案是信号槽再配合 Qt 的队列连接Queued Connection机制。信号槽是 Qt 跨线程通信的官方正统方案也是我在这篇文章里重点推荐的方式。1.3 什么时候该开子线程什么时候不该开判断一件事要不要开子线程其实有个很粗糙的指标这个操作会不会让界面在“肉眼可见”的时间内没反应。如果只是算个 100 万次循环可能 1 毫秒就完事了那没必要开线程线程切换本身也有开销。但如果是 HTTP 请求等网络返回、串口等待数据、读取一个几百 MB 的文件、对几千帧图像做处理这些动辄几十毫秒到几秒的操作就必须丢到子线程。另外一个被忽略的点是线程不是越多越好。Qt 线程本质上是操作系统线程的封装创建和销毁都有成本每个线程默认栈空间也不小。你可以在 UI 上放一个按钮每次点击就创建一个线程去干活但干完活线程立刻销毁如果点击频率高创建销毁的开销反而比任务本身还大。这种情况下更好的选择是线程池QtConcurrent::run或者QThreadPool。2. 主线程和子线程交互数据的几种方式2.1 信号槽跨线程最推荐的正统玩法信号槽是 Qt 跨线程传递数据的首选。子线程干活干完了发一个信号把结果作为参数带出去主线程槽函数收到信号后再更新界面。为什么说它安全因为 Qt 的跨线程信号槽默认走队列连接信号发出后不会立刻在接收线程里执行而是被封装成一个事件投递到接收线程的事件循环里。接收线程比如主线程在处理完当前事件后再去调用槽函数。这样一来子线程只是把数据“放到邮箱里”真正去更新界面的是主线程自己。两个线程没有在同一个时刻抢同一块内存安全性就有了保障。代码如下// worker.h class Worker : public QObject { Q_OBJECT public: explicit Worker(QObject *parent nullptr); public slots: void doWork(); signals: void progressUpdated(int percent); void resultReady(const QString result); };// main.cpp 关键部分 QThread workerThread; Worker worker; worker.moveToThread(workerThread); // 启动线程 QObject::connect(workerThread, QThread::started, worker, Worker::doWork); // 跨线程传数据子线程发信号主线程更新 UI QObject::connect(worker, Worker::progressUpdated, this, MainWindow::updateProgress); QObject::connect(worker, Worker::resultReady, this, MainWindow::showResult); workerThread.start();这里的核心关键点是worker.moveToThread(workerThread)。这句话改变了 Worker 对象的线程亲和性让它的槽函数在 workerThread 线程里执行。如果不写这句Worker 还在主线程里你连接started信号去执行doWork()那doWork()还是跑在主线程里界面照样卡死。2.2 直接调用 QMetaObject::invokeMethod 的场景信号槽适合“一对多”或者“事件驱动”的通信但有些时候你只是想让某个对象在指定线程里执行一个方法又不想为此专门定义信号槽。这时候可以用QMetaObject::invokeMethod// 在子线程里执行 worker 的 doWork 方法 QMetaObject::invokeMethod(worker, doWork, Qt::QueuedConnection);这个方法的作用和信号槽类似也是把调用包装成事件投递到目标线程。它能跨线程安全地调用任意 QObject 的槽函数或可调用方法。不过它没有返回值给你拿如果你需要拿到计算结果还是得靠信号传回来或者传入一个 lambda 在目标线程里捕获结果。如果你用的是 Qt 5.10 以上版本还可以用支持函数指针和 lambda 的重载版本QMetaObject::invokeMethod(worker, []() { // 在 worker 所在线程里执行这块代码 qDebug() thread: QThread::currentThread(); }, Qt::QueuedConnection);这个写法在一些临时需要切线程的场景下非常便利写起来比定义信号槽简洁不少但我自己还是习惯重要逻辑走信号槽毕竟编译期能检查类型也更安全。2.3 加锁共享变量高频小数据的无奈之选信号槽虽好但它有个特点投递到目标线程后要等事件循环去取这就带来了延迟。如果数据量非常大、更新频率特别高比如一个传感器每秒上报几千次数据每次都发信号、投递事件、主线程再处理事件队列会爆炸CPU 都耗在拷贝和排队上了。这种情况可以退一步搞一块“共享内存区域”用锁保护起来。子线程往里面写最新值主线程用定时器或者事件驱动的方式去看一眼最新值不需要每次变化都推送过来。比如用QMutex加一个double或QVectorclass SharedData { public: void setValue(double v) { QMutexLocker locker(mutex); value v; } double getValue() { QMutexLocker locker(mutex); return value; } private: QMutex mutex; double value 0.0; };这种方案适合“只保留最新值”的场景。比如实时波形显示你不需要把每一帧都发给主线程主线程用几十毫秒的定时器去取一下最新数据画出来就够了。但如果你要的是“每一条数据都不能丢”那还是得用队列或者信号槽。2.4 线程安全队列批量传递不丢数据的方案还有一种场景子线程产出一批数据主线程按自己的节奏消费。比如后台线程持续从串口读字节流主线程负责解析并显示。这时候用QQueue加锁或者直接用 Qt 6 里新加的QSlotObjectBase不太好搞常见做法是自己封装一个线程安全队列templatetypename T class SafeQueue { public: void push(const T t) { QMutexLocker locker(mutex); queue.enqueue(t); } bool pop(T t) { QMutexLocker locker(mutex); if (queue.isEmpty()) return false; t queue.dequeue(); return true; } private: QMutex mutex; QQueueT queue; };主线程里放一个QTimer每隔 50ms 从队列里取一批数据刷新 UI。子线程只管往队列里塞。这种方式比信号槽高效也不会丢数据代价是你自己要管理队列的容量和内存避免无限制增长。3. 实操案例一个后台线程持续出数据并实时更新状态栏3.1 场景设定我这里用一个非常常见的场景来跑通整个流程界面上有一个按钮“开始处理”点击后启动一个子线程子线程模拟一个耗时的计算任务比如对一段时域采样数据做 FFT 变换换算成频域数据计算过程中每完成一部分就发信号更新进度条计算完成后把结果发回主线程用一个 QCustomPlot 控件绘制频域波形。这个场景集合了热词里的几个点Qt 多线程、实时更新状态给主线程、QCustomPlot 显示频域图。整个架构搭好了以后换成 HTTP 下载、串口读取、数据库查询都是同一套逻辑。先定义好 Worker 类它不继承 QThread只继承 QObject。这是 Qt 官方推荐的写法任务和工作逻辑放在一个普通的 QObject 里然后通过 moveToThread 把这个对象扔到子线程去执行。class FftWorker : public QObject { Q_OBJECT public: explicit FftWorker(QObject *parent nullptr); public slots: void startFft(); void stop(); signals: void progressChanged(int percent); void fftFinished(QVectordouble frequencies, QVectordouble magnitudes); private: bool m_stopped false; QMutex m_mutex; }; void FftWorker::startFft() { // 模拟时域数据取 16384 个采样点做频谱分析 const int sampleCount 16384; QVectordouble input(sampleCount); for (int i 0; i sampleCount; i) { input[i] qSin(2.0 * M_PI * 1000.0 * i / 8000.0) 0.5 * qSin(2.0 * M_PI * 3000.0 * i / 8000.0); } QVectordouble magnitudes(sampleCount / 2); QVectordouble frequencies(sampleCount / 2); // 模拟 FFT 计算的分步过程用来汇报进度 for (int step 0; step 100; step) { { QMutexLocker locker(m_mutex); if (m_stopped) { return; } } QThread::msleep(20); // 模拟计算耗时 for (int i step * sampleCount / 100; i (step 1) * sampleCount / 100; i) { if (i sampleCount) { // 这里简化处理实际会做真正 FFT比如用 kissfft magnitudes[i % magnitudes.size()] input[i] * 0.001; } } emit progressChanged(step 1); } for (int i 0; i frequencies.size(); i) { frequencies[i] 8000.0 * i / sampleCount; } emit fftFinished(frequencies, magnitudes); } void FftWorker::stop() { QMutexLocker locker(m_mutex); m_stopped true; }3.2 主线程里启动线程和管理生命周期Worker 定义好了接下来是主窗口里接线。这里最容易踩坑的是线程和 worker 对象的生命周期。很多人的程序崩溃都是因为线程跑着跑着worker 对象被提前销毁了或者主窗口关掉了线程还在跑。安全的做法是把 QThread 和 Worker 都作为主窗口的成员变量在主窗口的析构函数里先请求线程停止再等待线程真正结束最后才销毁对象。// MainWindow 头文件 class MainWindow : public QMainWindow { Q_OBJECT public: MainWindow(QWidget *parent nullptr); ~MainWindow(); private slots: void onStartButtonClicked(); void onProgressChanged(int percent); void onFftFinished(QVectordouble frequencies, QVectordouble magnitudes); private: QThread m_workerThread; FftWorker *m_worker nullptr; QPushButton *m_startButton nullptr; QProgressBar *m_progressBar nullptr; QCustomPlot *m_plot nullptr; }; // 构造函数 MainWindow::MainWindow(QWidget *parent) : QMainWindow(parent) { m_startButton new QPushButton(开始处理, this); m_progressBar new QProgressBar(this); m_progressBar-setRange(0, 100); // 创建 QCustomPlot 控件并配置坐标轴 m_plot new QCustomPlot(this); m_plot-addGraph(); m_plot-xAxis-setLabel(频率 (Hz)); m_plot-yAxis-setLabel(幅值); m_worker new FftWorker(); m_worker-moveToThread(m_workerThread); connect(m_startButton, QPushButton::clicked, this, [this]() { if (!m_workerThread.isRunning()) { m_workerThread.start(); } QMetaObject::invokeMethod(m_worker, startFft, Qt::QueuedConnection); }); connect(m_worker, FftWorker::progressChanged, this, MainWindow::onProgressChanged); connect(m_worker, FftWorker::fftFinished, this, MainWindow::onFftFinished); } MainWindow::~MainWindow() { m_worker-stop(); m_workerThread.quit(); m_workerThread.wait(3000); // 最多等 3 秒 if (m_workerThread.isRunning()) { m_workerThread.terminate(); // 实在退不出再强制结束不推荐但是保底 } delete m_worker; }这段代码里有几个细节值得展开强调。第一点击按钮后调用QMetaObject::invokeMethod(m_worker, startFft, Qt::QueuedConnection)而不是直接调用m_worker-startFft()。原因在于m_worker已经 moveToThread 到工作线程了直接调用会让它在主线程里执行失去子线程的意义。用 QueuedConnection 投递过去startFft()才会在工作线程的事件循环里被真正执行。第二m_workerThread.quit()会让线程的事件循环退出但前提是当前没有正在执行的槽函数。如果槽函数里有一个while(true)死循环quit()也没用。所以在线程类里加一个m_stopped标志位循环体里定期检查发现要停止就赶紧退出这才叫优雅停机。第三wait(3000)的返回值要关注。如果返回 false说明线程卡住了没退出来这时候再考虑terminate()。terminate()非常粗暴可能直接杀掉正在执行到一半的代码锁没有释放、资源没有回收所以只能保底用生产环境最好别让它触发。3.3 信号槽的参数类型注册问题fftFinished信号里带了QVectordouble参数。如果你在跨线程连接信号槽时使用自定义类型比如自己定义的 struct、class需要提前注册元类型才能在线程间传递否则 Qt 会直接报警告并拒绝投递。对于QVectordouble这种 Qt 内置类型它已经注册过了直接用没问题。但如果结构体是你自己写的:struct SpectrumData { QVectordouble frequencies; QVectordouble magnitudes; }; Q_DECLARE_METATYPE(SpectrumData)必须要有这一行宏然后在程序启动时比如 main 函数里调用qRegisterMetaTypeSpectrumData(SpectrumData)。如果不注册跨线程队列连接时信号发不出去槽函数永远不会执行这是很多新手排查半天都找不到原因的经典坑。3.4 把数据交给 QCustomPlot 绘制当子线程计算完成发出fftFinished信号后主线程的槽函数onFftFinished会收到频域数据和幅值数据这个时候就可以更新 QCustomPlot 了void MainWindow::onFftFinished(QVectordouble frequencies, QVectordouble magnitudes) { m_plot-graph(0)-setData(frequencies, magnitudes); m_plot-rescaleAxes(); m_plot-replot(); m_progressBar-setValue(100); }这里的一切操作都发生在主线程所以 QCustomPlot 的刷新是安全的。数据从子线程到主线程的传递是通过信号参数拷贝完成的两个线程之间没有共享内存的竞争问题。不过要注意如果频域数据量非常大比如几十万个点每次信号拷贝整个 QVector 是有开销的。实际项目中可以把 QVector 换成QSharedPointer共享指针信号只传递指针代价是发送方不能再改这块内存但读取是安全的。这是一个性能优化思路用到的时候再琢磨也不迟。4. 高频踩坑实录与排查技巧4.1 界面还是卡死哪里出了问题如果你确认已经开了子线程界面还是卡死第一个怀疑对象就是你的耗时任务真的跑到子线程了吗用一个自带方法验证——在槽函数里打印当前线程的 ID看看和你预期的是否一致qDebug() current thread: QThread::currentThreadId();如果你在界面按钮的 lambda 里直接调用了worker.doWork()而不是通过信号槽或invokeMethod投递到子线程那它就是在主线程里跑的当然卡。检查优先级最高的问题有两个有没有moveToThread有没有用QueuedConnection去触发任务。第二个常见原因是子线程任务里调用了QThread::sleep()或者某个阻塞操作但因为某种原因一次执行时间过长比如 FFT 计算几秒钟这期间虽然 UI 线程空闲但槽函数迟迟不发进度信号用户就会觉得界面“像卡住了”其实就是缺少反馈。优化办法是把大任务拆成多个小步骤每跑完一步发一次进度信号。第三个原因可能是你在子线程里调用了 GUI 相关的函数比如QMessageBox::information()。这个函数在子线程里被调用时它是一个阻塞调用它会尝试在子线程里弹窗同时又依赖主线程事件循环去绘制窗口两边各等各的直接死锁。我见过不止一次有人为了图方便在子线程里弹框最后程序完全卡死任务管理器都杀不掉。4.2 信号发了但槽函数不执行信号槽不执行最常见的原因是连接类型不对或者元类型没注册。你自己定的规则是子线程发信号主线程接收。如果连接方式是默认的AutoConnectionQt 会根据发射信号的对象和接收对象所在线程自动判断一般都会退化成队列连接没问题。但如果你手动指定成了DirectConnection那这个信号会在子线程里直接调用主线程的槽函数相当于跨线程直接操作 UI行为未定义坑非常深。第二个原因是接收对象的事件循环没跑起来。主线程的事件循环由app.exec()撑起来如果主线程被某个死循环卡住事件循环转不动队列连接里的槽函数永远没有机会执行。子线程里如果你没有调用exec()开启事件循环那么通过队列连接投递到子线程的调用也一样不会执行。注意moveToThread之后的 QObject它的槽函数是通过事件驱动的线程必须进入事件循环才能响应队列信号。排查这类问题时建议在槽函数第一行加qDebug()输出一下确认到底有没有进来。如果你看到信号每次都发出来了但槽没打印十有八九是线程亲缘性问题或者连接方式问题。4.3 程序突然崩溃常见在这里崩溃集中在几个地方。第一是对象生命周期子线程任务还没结束你关掉窗口MainWindow 析构函数里直接 delete 了 worker或者让局部变量被回收子线程还在访问一块已经释放的内存必崩。解决方式就是前面演示的做法先stop()、再quit()、再wait()最后才 delete。第二是 lambda 捕获了this。在 Qt 5 里信号槽可以用 lambda但如果你在 lambda 里捕获了主窗口的this指针而这个 lambda 被投递到子线程执行你在 lambda 里访问了 UI 控件就违规了。而且如果主窗口已经关了lambda 还拿这个悬空的 this 去操作崩溃是迟早的事。解决方式是要么不跨线程时用 lambda要么在捕获里把this换成 QPointer 做安全判断。第三是容器类的并发读写。前面说了QVector、QList、QMap这些容器不是线程安全的。子线程往 vector 里 push_back主线程同时读 front()内存会被踩坏这种崩溃的随机性极强可能跑十几次才崩一次非常难查。应对方法就是加锁或者用线程安全队列又或者干脆用std::atomic处理简单的计数器、布尔值。4.4 线程数量怎么控制有些同学一看界面卡就把所有功能都各自开一个线程最后程序里跑着十几个线程内存飞涨CPU 反复切换上下文性能反而更差。正确思路是区分 IO 密集型和 CPU 密集型。IO 密集型HTTP、串口、文件读写的线程可以多一点因为大部分时间它们都在等。CPU 密集型FFT、图像处理、加密解密的线程数最好不要超过 CPU 核心数开多了反而因为线程切换变慢。如果业务是短小的任务每次执行只有几十毫秒到几百毫秒强烈建议用QtConcurrent::run配合QThreadPool它内部维护了一个线程池任务执行完线程会被回收重用避免了反复创建销毁线程的开销。架构上也更简洁不必去写 QThread 的完整生命周期管理QtConcurrent::run([this]() { QVectordouble result heavyCalculate(); emit resultReady(result); });QtConcurrent::run会把 lambda 提交到全局线程池由线程池分配线程执行。执行的是你自己的耗时逻辑里面可以安全地 emit 信号主线程收到信号该干嘛干嘛。线程池内部的线程默认是复用的而且会自动处理线程的创建和销毁业务代码省心很多。4.5 关于启动时报错和打包问题热词里有两个跟线程没直接关系但大家总来问的报错我也顺带提一嘴。一个是windows no qt platform plugin could be initialized这通常是你把发布目录里的platforms插件丢了或者程序运行路径下找不到 qwindows.dll。解决办法是使用windeployqt工具自动部署依赖库或者手动把 Qt 安装目录的plugins\platforms文件夹拷贝到 exe 旁边。另一个是:-1: error: dependent ..\..\..\..\allinstall\qt\5.15.2\msvc2019\include\qtw...这是 Qt 库路径配置问题项目文件里引用的 Qt include 路径指向了一个不存在的目录重新在 Qt Creator 的构建套件里选择正确的 Qt 5.15.2 安装路径就能解决。这类问题看着吓人其实就是环境变量、路径配置的问题。最后分享一点个人体会我自己的习惯是所有跨线程交互都先默认为信号槽除非有明显的性能瓶颈再考虑共享内存加锁的方案。因为信号槽的思路简单直观而且 Qt 帮你处理了线程切换、事件投递这些底层细节容错率高。写了几年线程代码之后我发现真正复杂的不在于 API 怎么用而在于要想清楚对象的生命周期归属这个对象属于哪个线程、它的事件循环在哪里跑、它什么时候销毁。把这个想明白了Qt 多线程也就拿下了一半。还有一个实用小技巧调试多线程程序时给每个线程起个可识别的名字。在main函数里用QThread::currentThread()-setObjectName(GUI)子线程里可以命名为 Worker1再用 qDebug 输出线程名字排查问题的时候日志一目了然不用费劲去对线程 ID。虽然是小事但在复杂的并发场景里这一丁点便利能省下不少心力。本文还有配套的精品资源点击获取
返回列表