ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Qt多线程实战:掌握QThread与信号槽,彻底告别界面卡顿

Qt多线程实战:掌握QThread与信号槽,彻底告别界面卡顿 记得我第一次在Qt里认认真真写多线程是一个数据采集的项目。主界面要实时刷新曲线底层硬件通过串口不断往上抛数据一开始图省事直接在槽函数里做解析、滤波、绘图结果程序一跑起来窗口直接卡成PPT鼠标拖一下都要等半天。那时候我才意识到Qt多线程不是会用QThread就完事的事情它牵扯到事件循环、信号槽连接方式、线程亲缘性这些底层机制搞不明白代码写再多也是白搭。这篇是Qt中多线程的使用系列的第一篇我打算把最核心、也最容易踩坑的地基部分讲透为什么耗时任务必须扔出主线程、跨线程通信为什么首选信号槽、两种主流多线程写法分别适合什么场景以及线程安全的基本功。适合刚接触Qt多线程、被界面卡顿折磨过、或者想系统梳理QThread用法的朋友看完可以直接在项目里落地。1. 界面卡顿的根源耗时任务拖垮了谁1.1 事件循环的本质UI线程就是个接单-派单的循环很多人写Qt界面程序写了很久都没意识到main函数里那个app.exec()到底在干什么。我打个比方主线程就像一个只有一个人的餐厅服务员他站在柜台前接一个订单就处理一个。exec()启动之后这个服务员进入一个无限循环——窗口的鼠标点击、键盘输入、定时器超时、网络数据到达统统会转化成一个个事件放进队列里服务员按顺序把它们取出来分发到对应的槽函数去执行。问题就出在按顺序这三个字上。如果某个槽函数特别耗时比如读一个大文件、做一次复杂的计算、等待网络响应那么这个服务员就被这件事拖住了后面排队的订单全部积压。反映到界面上就是窗口无法拖动、按钮点了没反应、标题栏显示未响应。所以多线程的第一诉求从来不是炫技而是把主线程从耗时任务里解放出来。只要主线程的事件循环能始终保持秒回状态你的界面就不会卡。1.2 量化一下到底多慢的操作才需要扔出主线程我经常被问一个问题我的槽函数里就做个字符串拼接需要开线程吗我的判断标准很简单拿秒表实测或者直接在代码里用QElapsedTimer量一下耗时在10毫秒以内的主线程扛一扛问题不大毕竟人眼帧率也就16毫秒一帧。耗时在几十毫秒到几百毫秒的已经能感觉到明显的顿挫感了比如下拉菜单卡一下、窗口拖动不跟手这时候就应该考虑异步化。超过1秒的不用犹豫必须扔出主线程。否则用户会以为程序崩溃了。但是这里有个隐藏陷阱。有时候单个操作不快可它是高频触发的。比如串口每50毫秒收到一帧数据槽函数虽然只处理5毫秒CPU占用率看起来不高可主线程在总时间里的可调度窗口被压缩了界面照样会时不时掉帧。这种短而频繁的任务同样需要线程来兜底。1.3 卡顿的连锁反应从QTimer失灵到窗口假死在主线程被阻塞的这段时间里不只是界面刷新停了连QTimer都会跟着失灵。这是因为定时器本质上也是依赖事件循环来触发的。主线程卡死定时器事件发不出去该超时的超时不了该重连的重连不了。很多人在串口通信程序里遇到过通讯超时误报的问题排查了半天硬件其实根源就是主线程被某段耗时逻辑卡住超时定时器根本没机会触发。再严重一点Windows系统检测到窗口消息长时间得不到处理会直接给你弹一个程序未响应的对话框。用户的第一反应就是结束进程。所以我的经验是只要进入主线程的任何一个槽函数可能阻塞超过100毫秒就应该停下来想想这段代码能不能放到工作线程里去。2. 线程与界面通信的唯一正道理解信号槽的跨线程语义2.1 三种连接方式Direct、Queued、Auto到底怎么选把任务丢到子线程之后紧接着就遇到新问题子线程算完的结果怎么回传到界面Qt给的答案很明确——信号槽。但信号槽不是无脑connect就完事的它背后有一套连接方式的选择逻辑这三种方式分别对应不同的执行场景Qt::DirectConnection信号emit的时候槽函数直接在发射信号的那个线程里同步执行。子线程发信号子线程自己把槽跑完再继续往下走。Qt::QueuedConnection信号emit的时候会打包成一个事件投递到接收者所在的线程事件队列里。如果接收者住在主线程那么这个槽函数会在主线程空闲时被执行。Qt::AutoConnection默认值。Qt根据发射者和接收者是否在同一个线程来自动选择——同线程用Direct跨线程用Queued。跨线程通信的时候AutoConnection会自动落到QueuedConnection上这正是我们要的行为子线程通过信号把数据寄给主线程主线程在自己的事件循环里处理这些数据既完成了线程切换又天然做到了线程安全——因为槽函数的执行被串行化到主线程了不存在两个线程同时走进同一段代码的风险。这一点特别重要值得展开说一下。Qt多线程开发的黄金法则是工作线程里只做计算和IO所有涉及界面的操作都通过信号发回主线程处理。这条法则的本质就是利用QueuedConnection把对界面的访问全部串行化。2.2 为什么跨线程直接更新UI是禁忌刚学Qt多线程的时候我犯过一个特别典型的错误在子线程里直接调用ui-label-setText()。当时Qt没崩溃只是偶发异常时好时坏这种程序看着能跑但总感觉哪里不对劲的状态最折磨人。原因在于Qt的UI对象并不是线程安全的它们大多依赖主线程的事件循环来维护内部状态。你在子线程里改了label的文字另一瞬间主线程正好在重绘这个label两个线程同时访问同一份数据轻则显示错乱重则直接崩溃。Qt文档里写得很直白只有主线程可以操作UI对象其他线程只能通过信号槽把请求转交给主线程。后来我养成了一个习惯所有工作线程里禁止直接include界面的头文件更不允许存一个UI指针到处传。需要通知界面更新的数据统一走信号发射出去。这样写还有个额外好处工作线程和界面层彻底解耦我把界面从QWidget换成QML工作线程一行代码都不用改。2.3 实测一个最简单的跨线程信号槽demo光说不练假把式我给一个最简demo。场景是点击按钮后子线程做一个耗时计算每隔100毫秒向主线程汇报一次进度结束后返回结果。// worker.h class Worker : public QObject { Q_OBJECT public: explicit Worker(QObject *parent nullptr); signals: void progressUpdated(int value); void finishedWithResult(int result); public slots: void doTask(); }; // worker.cpp void Worker::doTask() { int sum 0; for (int i 0; i 100; i) { // 模拟耗时计算 QThread::msleep(100); sum i; emit progressUpdated(i 1); } emit finishedWithResult(sum); }主线程那边创建一个QThread对象把Worker的doTask通过信号槽触发// 主窗口部分代码 Worker *worker new Worker; QThread *thread new QThread(this); worker-moveToThread(thread); connect(thread, QThread::started, worker, Worker::doTask); connect(worker, Worker::progressUpdated, this, [this](int value) { ui-progressBar-setValue(value); }); connect(worker, Worker::finishedWithResult, this, [this](int result) { ui-label-setText(QString::number(result)); }); thread-start();这个demo里最关键的一点是progressUpdated和finishedWithResult都是跨线程信号连接方式自动采用QueuedConnection所以progressBar和label的更新被安全地调度回主线程执行。整个过程没有手动加锁也不需要QMetaObject::invokeMethod那些花活代码整洁且稳定。3. 两种主流多线程写法与选型逻辑3.1 方式一继承QThread重写run()这是最古老也最直观的写法。继承QThread重写run()函数把需要并发执行的逻辑放进run()里然后start()启动线程。class WorkThread : public QThread { Q_OBJECT protected: void run() override { // 在这里执行耗时逻辑 for (int i 0; i 100; i) { QThread::msleep(100); emit progressUpdated(i 1); } } signals: void progressUpdated(int value); };这种写法在很多老项目里随处可见因为它最好理解——把线程当成一个可以并发执行的东西。但它有一个隐蔽的坑QThread对象本身所在的线程和run()实际执行的线程并不是同一个。你的子类对象可能创建在主线程但run()的代码跑在新线程里。如果在WorkThread的构造函数里connect信号连接方式是DirectConnection还是QueuedConnection取决于发射信号和执行槽的线程是否相同初学者很容易被绕晕。所以这种写法我现在的态度是只适合没有复杂交互、一次性跑完就退出的场景。比如程序启动时加载一个资源配置文件加载完发个信号就结束用继承QThread完全够用。3.2 方式二moveToThread让QObject跑在子线程另一种写法是官方更推荐的不继承QThread而是创建一个普通的QObject工作对象通过moveToThread()把它整体搬到子线程里再由线程的started信号触发工作对象里的槽函数。这种方式的核心机制是一个QObject对象它关联的事件循环所在线程决定它的线程亲缘性。当你调用moveToThread之后这个对象收到的事件包括QueuedConnection投递过来的信号调用会在目标线程的事件循环里被处理。换句话说工作对象里的所有普通槽函数天然地跑在子线程里代码逻辑和工作线程绑定得更干净。class CleanWorker : public QObject { Q_OBJECT public: explicit CleanWorker(QObject *parent nullptr); public slots: void process(const QString input); signals: void progressUpdated(int value); };主线程做这样的组装auto *thread new QThread(this); auto *worker new CleanWorker; worker-moveToThread(thread); // 在用完worker后由thread负责清理 connect(thread, QThread::finished, worker, QObject::deleteLater); connect(worker, CleanWorker::progressUpdated, this, [this](int value) { ui-progressBar-setValue(value); }); thread-start();这里还有一个细节要注意QThread::finished信号在C11之后变成private的了不能直接connect到deleteLater上。安全的做法是connect到thread对象上或者直接在finished信号对应的lambda里去执行清理。我一般这样写connect(thread, QThread::finished, worker, QObject::deleteLater);这段代码能编译说明Qt版本里finished还是可访问的。如果编译器报错就改用connect(thread, QThread::finished, worker, QObject::deleteLater)放不进lambda的写法或者给QThread子类化封装一层清理逻辑。总之重点不是这段代码能否在你的版本里编译而是要理解线程结束之后工作对象不会自动销毁忘记delete会导致内存泄漏随手delete又可能踩到对象还在跑的野指针所以deleteLater是标准解法。3.3 选型对比什么时候用哪个我在项目里定了一个不成文的选择标准分享出来给大家参考。场景推荐写法原因一次性后台任务不需要中途停、不需要反复传递数据继承QThread 或 QtConcurrent::run代码最简洁心智负担小常驻后台的工作对象比如串口收发、网络轮询、实时数据处理moveToThread QObject工作对象生命周期清晰信号槽交互自然方便优雅退出需要把任务分解成多个可取消、可并行的单元QThreadPool QRunnable线程由线程池统一管理避免频繁创建销毁线程的开销简单异步执行一次函数QtConcurrent::run一行代码搞定适合不需要交互的纯计算或IO我个人的主力写法是moveToThread这条路。说不上它比继承QThread绝对好但它的归属感更明确工作对象属于子线程所有槽都在子线程执行该发信号发信号该接收命令接收命令结构清晰也好排查问题。4. 线程安全不是可选项共享数据与资源竞争4.1 QMutex加锁的实际写法就算你严格遵循工作线程不碰UI的原则多线程项目里还是免不了会遇到共享数据的问题。最常见的是工作线程写缓存主线程要读取统计信息或者两个工作线程同时往一个容器里追加数据。这种情况不加锁后果是未定义行为——可能运行一万次才崩一次崩的时候还毫无规律。QMutex是最基础的互斥锁。标准用法是这样class SafeCounter { public: void increment() { QMutexLocker locker(m_mutex); m_count; } int value() const { QMutexLocker locker(m_mutex); return m_count; } private: mutable QMutex m_mutex; int m_count 0; };这里我刻意用了QMutexLocker而不是手动lock()/unlock()。锁这个东西最怕的就是忘了解锁——函数中间来个return或者抛出异常锁就永远锁死了。QMutexLocker利用RAII机制在作用域结束的时候自动解锁无论代码走哪条分支都不会漏。为什么多次一举用锁因为主线程和工作线程可能同时访问m_count比如一个线程在value()里读另一个线程在increment()里写。没有锁的话读到的可能是写到一半的脏数据。加锁之后读和写都串行化了数据一致性有了保证。4.2 死锁是怎么产生的如何避免如果说加锁是给自己上保险那死锁就是保险买多了反而出问题。我举一个最典型的场景线程A持有锁1等待锁2线程B持有锁2等待锁1。两边谁都不肯放程序就卡死在原地。Qt里排查死锁有个小技巧程序卡死的时候用调试器暂停查看每个线程的调用栈找到锁的持有关系画一张等待图。如果形成一个环那必死锁。避免死锁的口诀就两条我天天挂在嘴边加锁顺序必须全局一致。如果代码里约定先锁A再锁B那所有线程都必须遵守这个顺序绝不能有的线程先锁B再锁A。尽量缩小锁的范围。不需要锁的代码别放进临界区。比如算个哈希可能要几十毫秒把耗时计算放锁外面锁只保护真正的共享变量。还有一种隐蔽的死锁主线程在等待子线程结束而子线程通过QueuedConnection发信号给主线程处理可主线程已经卡在等待子线程结束上根本没机会处理信号。这就成了一个循环等待。解决方案通常是把等待结束改成收到finished信号后再做收尾彻底放弃同步等待。4.3 线程退出与QThread销毁的坑程序退出的时候千万别直接delete一个还在运行的线程。QThread::destroyed的时机如果不对很可能直接把整个程序带崩。我曾经在析构函数里写过thread-deleteLater()结果线程还在跑deleteLater事件没被处理程序退出时崩溃排查了很久才发现是对线程生命周期管理不当。正确的退出姿势分三步第一步调用thread-quit()或thread-requestInterruption()让线程的事件循环在合适的时机退出第二步调用thread-wait()阻塞等待线程真正结束可以带一个超时参数比如wait(3000)防止线程卡死不退出第三步等finished信号触发了再去做delete或deleteLater。这里尤其推荐requestInterruption()配合isInterruptionRequested()使用。在耗时循环里定期检查中断标志收到中断请求就直接跳出循环比粗暴地terminate()优雅得多。terminate()是任何时候都不要用的——它可能在线程持有锁的时候直接杀掉线程锁永远不释放其他线程全部死锁。5. 实践中踩过的坑从崩溃现场到排查思路5.1 崩在QTimer::start错误的线程亲缘性有段时间我写一个后台轮询服务创建了一个QObject工作对象里面有个QTimer每5秒触发一次网络请求。我在主线程里把它moveToThread到子线程然后直接调用workTimer-start()。程序正常运行了几天突然在某次代码改动后崩溃报错信息指向QTimer::start——Timers cannot be started from another thread。这个报错特别经典。QTimer是有线程亲缘性的它依赖创建它所在线程的事件循环来触发。我在主线程里把moveToThread之后的QTimer调用了start()此时QTimer虽然已经被搬到子线程但start()是在主线程执行的触发了Qt的线程安全检测直接qFatal。正确的做法是QTimer的创建和start()都要在工作对象自己的线程里执行。也就是说把定时器启动逻辑写进工作对象的一个槽函数这个槽函数通过QueuedConnection在子线程里被调用这样定时器才能真正住在子线程里。或者更简单直接在doTask槽函数开头创建并启动QTimer保证它从一开始就归属子线程。5.2 界面卡死但程序没崩死锁与事件循环阻塞的鉴别另一类头疼的问题是界面卡死但程序进程还在任务管理器里能看到CPU占用率接近0说明程序不是在干活而是彻底堵住了。遇到这种情况我会先问自己一个问题主线程是阻塞在某个同步等待上还是事件循环因为某种原因退出了排查手法是直接给进程发一个调试中断在调试器里看主线程的调用栈。如果看到QThread::wait()阻塞在某一帧那十有八九是死锁。对着调用栈里的锁地址再去查其他线程是否持有同一把锁没释放。如果主线程的调用栈停在exec()里、事件循环还在跑但界面依然卡那就可能是某个事件处理函数执行时间太长单次事件把事件循环拖住了。我之前踩过的一个坑是主线程里通过信号槽等待子线程的结果用的是QMetaObject::invokeMethod加Qt::BlockingQueuedConnection。这个API本身没问题问题是主线程等待的时候窗口关闭事件也排着队没人处理于是出现程序看起来活着但界面关不掉的诡异现象。后来改成非阻塞的信号槽流程问题立刻消失。5.3 线程结束后还能收到信号还有一个让很多人困惑的现象线程已经finish了为什么还能收到从线程对象发出来的信号比如线程最后发了一个finished信号某个槽在主线程里执行页面提示任务完成。这背后其实是QueuedConnection在起作用——信号发送时接收者的事件队列里塞了一个事件哪怕发送信号的那个线程已经结束这个事件依然会排队等待主线程处理。所以不要奢望线程结束后立刻清理现场。所有基于信号槽的回调都可能存在一个信号已经发出但槽函数还没来得及执行的时间窗口。在这个窗口里如果你把相关的数据给delete了槽函数执行的时候就会访问到野指针。我的原则是凡是信号槽会传递的参数在槽函数执行完之前都不要主动销毁。如果确实需要在线程结束后释放资源务必使用deleteLater它会把销毁动作压到事件循环里确保排队的事件都处理完之后再执行清理。写在最后的个人体会聊了这么多Qt多线程最核心的东西其实就三句话主线程只负责界面和事件循环耗时操作交给子线程跨线程通信一律走信号槽。把这三条刻在脑子里很多坑你根本不会踩到。我理解很多初学者一上来就想搞懂QThreadPool、QtConcurrent、QRunnable这些高级玩法但我的经验是先把手动的QThread用明白把线程亲缘性、事件循环、连接方式这些概念吃透后面那些封装好的高级API在你眼里就是透明的。地基没打牢楼盖得再高也是危房。这个系列的第一篇就先讲到这里下一篇我打算重点聊聊QtConcurrent和QThreadPool的实战用法以及如何在任务队列里做并发控制和进度汇总。如果你在跟着练习的时候遇到线程崩溃、界面卡死、或者数据错乱的问题别急着改代码先把事件循环和信号槽连接的逻辑理一遍多半能找到症结。
返回列表