Qt多线程编程:QMutex互斥锁实战与死锁排查
如果你已经写过几个QThread自认为对 Qt 多线程多少有点感觉了那你大概率会在某一天遇到一个问题数据莫名其妙的错乱、程序偶发的卡死、或者一个加了锁照样崩溃的 bug。我最近在做一个某跨平台客户端的数据采集模块时就栽在了一个看起来简单到不行的count上。复盘之后发现问题根源全在 Qt/C 互斥锁的理解和使用边界上。这篇是 Qt/C 学习记录的第一篇我会把QMutex相关的原理、用法、踩坑复盘都整理出来。内容覆盖数据竞争的本质、QMutex/QMutexLocker的完整姿势、死锁复盘、锁和 Qt 事件循环之间的纠缠以及读写锁和锁粒度取舍。适合刚接触 Qt 多线程、或者写了一阵子QThread但总感觉锁没用明白的人。1. 先别急着加锁理解数据竞争才是前提1.1 一个让调试器也查不出问题的count先看我最开始提到的事故。某采集模块里有一个全局计数器用来统计收到的数据包序号两个生产者线程各自在往同一个累加变量上做count。// 某采集模块的数据包计数器 int packetCount 0; void producerThreadA() { for (int i 0; i 100000; i) { packetCount; } } void producerThreadB() { for (int i 0; i 100000; i) { packetCount; } }按理说两个线程各跑十万次最终packetCount应该是二十万。但我跑出来的结果经常是十九万七八甚至更低而且每次数字都不一样。关键在于packetCount并不是一条不可分割的指令。它在底层至少拆成三个动作把packetCount从内存读到寄存器、寄存器加一、再把结果写回内存。两个线程同时执行这段代码完全可能出现下面的交错线程 A 读了count 100还没写回线程 B 也读了count 100线程 A 写回101线程 B 写回101两个线程各加了一次但结果只增加了 1。这就是教科书里讲的丢更新lost update。更麻烦的是这种交错出现的概率并不高所以程序可能跑几分钟、几小时才出现一次异常。你用gdb打断点调试器跑到临界区之前一切正常越调试越觉得见鬼。当时我第一反应是用std::atomic但既然整套代码都在 Qt 体系里我更倾向保持风格统一于是在这个变量周围加了个QMutex。锁一加上去计数器立刻稳定成了二十万。1.2 什么场景才真正需要互斥锁被这次事故教育之后我把该不该加锁的判断标准整理了一下场景是否需要互斥锁说明多个线程只读同一份数据且没有写操作不需要纯读取不存在竞争谁先读谁后读不影响结果只有一个线程写多个线程读且不关心实时性看情况可以用双缓冲、快照或信号槽异步传递来避免锁多个线程写或者存在读-改-写模式需要这是互斥锁的核心适用场景共享一个队列或缓冲区生产和消费频率不同需要队列判空、入队出队必须保证原子的状态一致很多人犯的错是看到多线程就加锁结果锁了一大堆程序慢不说还制造了死锁风险。真正要关注的是共享数据、至少一个写者、存在非原子操作序列三者同时成立才需要认真考虑互斥。1.3 Qt 中锁的两个隐藏维度所有权与线程亲和性在 Qt 里讨论锁不能只看能不能锁住数据还要看 Qt 特有的数据所有权和线程亲和性概念。每个QObject都从属于创建它的线程也就是线程亲和性。如果一个对象是在主线程里创建的那它的槽函数正常情况下应该由主线程事件循环执行。你用moveToThread把对象挪到另一个线程这个对象的槽才会在那边跑。这种机制配合信号槽的跨线程投递经常让人误以为信号槽已经帮我做了线程安全结果底层共享的普通 C 容器一样被打爆。所以我的结论是Qt 信号槽解决的是谁来执行的问题互斥锁解决的是能不能同时碰数据的问题两者根本不冲突也互相替代不了。后面第 4 节我会专门说锁和事件循环混在一起时的坑这里先记住这句话。2. QMutex 入门lock、unlock、tryLock 到底该怎么用2.1 三种基本操作的语义和适用边界QMutex的基本操作看起来只有lock()、unlock()和tryLock()但每一类都有自己的坑。QMutex mutex; // 默认非递归锁 void safeIncrement() { mutex.lock(); packetCount; mutex.unlock(); }lock()是阻塞式的如果锁已经被别人持有当前线程会一直挂起等待直到拿到锁。问题在于挂起期间你什么都干不了如果临界区太长或者持锁线程出了异常整个程序可能僵住。tryLock()会立即返回一个bool不会阻塞。它的带参数版本tryLock(int timeout)可以等待指定毫秒数超时返回false。这种非阻塞方式非常适合拿不到锁就先干别的事的场景比如周期性刷新 UI 的时候主线程不应该为了等一个后台数据锁而把自己卡死。一个很容易踩的坑是非递归锁重入。QMutex默认是非递归的同一个线程在已经持有锁的情况下再次lock()同一个锁会直接死锁。QMutex mutex; void inner() { QMutexLocker locker(mutex); // 第二次加锁 } void outer() { QMutexLocker locker(mutex); // 第一次加锁 inner(); // 死锁 }如果你确定需要在一个线程内多层函数重入同一把锁那就改用QRecursiveMutex。它能记录 owner 线程和加锁次数同一个线程可以反复加锁解锁到计数归零才真正释放。但默认情况下能用非递归锁就用非递归锁因为它能逼着你把锁粒度想清楚。2.2 为什么我一直劝你用 QMutexLocker 而不是手写 lock/unlock手写lock()/unlock()最大的问题是异常安全和提前返回。// 看起来很合理的临界区 void updateCache(int id, const QByteArray data) { mutex_.lock(); if (!cache_.contains(id)) { mutex_.unlock(); return; // 这里的 unlock 容易漏 } cache_[id] data; mutex_.unlock(); }if分支稍微多一两个或者临界区里抛了个异常最后一个unlock()就永远执行不到。锁一旦没释放其他线程全部挂起程序表现为点一下就无响应。QMutexLocker做的就是 RAII 式的自动管理构造函数拿锁析构函数释放不管正常返回还是异常抛出锁都会解锁。void updateCache(int id, const QByteArray data) { QMutexLocker locker(mutex_); // 加锁 cache_[id] data; // 临界区 if (!cache_.contains(id)) { return; // 自动解锁安全返回 } // ... } // 离开作用域自动解锁有用但容易忽略的一个点QMutexLocker的锁粒度取决于它的作用域。你把它定义在函数开头那整个函数都在临界区里把它定义在一个{}块内锁只覆盖这个块。想减少锁的持有时间最直接的办法就是让QMutexLocker晚点构造、早点离开作用域。2.3 一个完整的生产者消费者队列案例很多人学QMutex时只写给变量加锁这种 demo到了真正的生产-消费模型里就懵了。这里我给一个可以直接抄的缓冲队列套路组合了QMutex和QWaitCondition。经典场景一个线程往队列里塞数据另一个线程取数据去网络发送。如果只靠互斥锁消费者线程在队列为空时会陷入反复查看队列有没有数据的忙碌等待白白烧 CPU。正确方式是锁配合条件变量。class SafeQueue : public QObject { public: void push(int v) { QMutexLocker locker(mutex_); queue_.enqueue(v); cond_.wakeOne(); // 通知一个等待线程 } bool pop(int v, int timeoutMs 1000) { QMutexLocker locker(mutex_); while (queue_.isEmpty()) { if (!cond_.wait(mutex_, timeoutMs)) { return false; // 超时还没数据 } } v queue_.dequeue(); return true; } private: QMutex mutex_; QWaitCondition cond_; QQueueint queue_; };这里最关键的细节是QWaitCondition::wait(mutex_, timeoutMs)它会先把这个mutex_解锁然后阻塞当前线程直到被wakeOne()/wakeAll()唤醒或者超时。被唤醒之后它会在返回前重新拿到锁。也就是说wait 返回的那一瞬间锁一定又在自己手里了所以外面套的QMutexLocker不需要做任何额外处理。while循环代替if是另一个关键点。条件变量存在假唤醒的可能线程可能在没有任何人wake的情况下醒来或者多个消费者被同时唤醒但队列里只有一个数据。用while (queue_.isEmpty())重新检查条件能够保证拿到锁之后依然满足数据可用条件再继续往下走。这个写法我建议永远不要改动它。2.4 tryLock 与超时锁的使用边界tryLock()适合两类场景一是UI线程避免阻塞宁可这帧不更新数据也不卡界面二是检测死锁或进行优雅降级。我用过一个具体的做法后台线程把最新的传感器数据写到一个共享缓冲里UI 线程每 16 毫秒刷新一次。UI 线程用tryLock(5)尝试拿锁拿到了就拷贝拿不到就继续画上一帧的数据绝不在锁上原地等。void refreshUI() { QByteArray snapshot; if (bufferMutex_.tryLock(5)) { snapshot buffer_; bufferMutex_.unlock(); } // 无论拿没拿到锁都正常刷新界面 }但tryLock也有一个隐蔽代价它判断拿不到锁之后UI 画的是旧数据可能造成视觉滞后。如果业务上要求必须显示最新数据那应该反过来考虑双缓冲或者直接无锁队列而不是一直抱着锁不放。锁永远是用来保证一致性的不是用来解决延迟的。3. 死锁不会咬人但会挂线程两次典型事故复盘3.1 事故一持锁等待线程结束程序直接僵死某系统在做初始化时主线程需要等一个工作线程加载完配置文件再继续。我把取配置和等线程退出写到了一起结果程序启动后直接不再响应。复盘时的关键调用栈大概是这样线程卡住位置持有的锁主线程QThread::wait()配置文件锁工作线程mutex.lock()等待配置文件锁无主线程在持有配置文件锁的情况下调用workerThread-wait()这个wait()会一直阻塞直到工作线程退出。工作线程退出前需要读配置文件因此去抢配置文件锁——可这把锁还被主线程握着。于是主线程等子线程退出子线程等主线程放锁互相等死。修复方案有两种。第一种是让主线程先放掉锁再等退出void init() { { QMutexLocker locker(configMutex_); loadConfig_locked(); } workerThread_-wait(); // 锁已经释放可以安全等待 }第二种是把数据拷贝出来不让子线程在退出路径上去碰共享配置。我最后选了先释放锁再wait()因为这种方式改动小、意图也更直白。这条事故留给我一个检查清单QThread::wait、同步 I/O、耗时的网络请求都不能出现在持锁的临界区内。持锁期间唯一应该做的就是操作受保护的数据本身。3.2 事故二两个锁的 ABBA 顺序排查了两小时第二次事故更经典是两个互斥锁交叉申请导致的死锁。A 线程要先拿锁 A 再拿锁 BB 线程恰好反过来先拿锁 B 再拿锁 A。// 线程 1 lockA.lock(); lockB.lock(); // 临界区 lockB.unlock(); lockA.unlock(); // 线程 2 lockB.lock(); lockA.lock(); // 临界区 lockA.unlock(); lockB.unlock();如果线程 1 拿了锁 A线程 2 拿了锁 B然后两个线程都在等对方释放另一把锁游戏就结束了。表面现象是程序运行到某个节点后半天没有日志输出但 CPU 占用是个位数显然不是计算量大导致的慢。这种问题靠读代码往往很难一眼发现因为两个加锁点可能分散在不同文件里相隔几百行。我是怎么定位的把所有lock()和unlock()用 grep 列出来按文件顺序排了一张表然后把每个线程的加锁顺序画出来对比才看见 ABBA 的交错。出了这次事故之后这类一对多的锁设计我基本不会再用了。真需要在同一临界区拿多个锁优先考虑QMutex做不到而 C17 能做的事用std::scoped_lock一次性按固定顺序锁多个互斥量。// 同一次操作需要锁两个互斥量时 std::scoped_lock lock(lockA, lockB); // 同时持有 A、B按传入顺序加锁避免死锁3.3 避免死锁的几条基本盘复盘这两次事故后我给自己定了几个规矩现在写代码时还贴在旁边同一组锁在所有线程里保持同一个加锁顺序。如果一定要锁 A 再锁 B那所有线程都必须遵守这个顺序不搞特殊。缩小锁的持有范围持锁时间越短和别人交叉等待的机会越小。能用一把锁完成的操作不要拆成两把。不是锁越多越好细粒度锁提升的并发性很可能抵不过死锁排查的成本。用完工具检测。我习惯隔一段时间用 Thread Sanitizer 跑一遍程序数据竞争和部分死锁问题会直接报出来。配置很简单在 .pro 或者 CMake 里加-fsanitizethread即可但注意要专门开一个 debug 构建跑测试不要带着 sanitizer 上生产。4. 锁与 Qt 事件循环一个经常被忽略的坑4.1 持锁 emit 信号槽卡住了整个事件循环Qt 的跨线程信号槽默认走QueuedConnection发送方把事件投递到接收线程的事件队列然后立即返回接收线程在自己事件循环里取出事件调用槽函数。理论上槽函数执行时机和发送方不在同一个调用栈里。但问题是锁和事件循环都有等待语义两者叠在一起会制造出非常隐蔽的假死状态。我遇到过一次这种情况发送线程持有配置锁修改完配置后emit configChanged()然后把锁释放。结果接收线程的事件循环里跑着另一个模块它在处理某个槽函数时企图拿同一个配置锁于是整个接收线程的事件循环阻塞在锁上。发送线程虽然很快释放了锁但由于接收线程已经阻塞它后面排队的事件全都执行不了包括我们期待执行的某个重试逻辑。从日志看程序还有心跳在发只是业务逻辑永远不再往前走了。这类问题排查起来比死锁更头疼因为线程并没有真正死锁锁最终会被释放但事件循环被卡住的这段时间内应用表现已经不正常了。我的结论跨线程信号槽本身不是锁但它依赖接收线程事件循环的及时运转任何长时间阻塞接收线程的锁操作都在间接破坏信号槽机制。持锁触发跨线程信号时要格外小心最好先拷贝数据、释放锁再emit让槽函数拿到的是快照而不是去抢共享资源。4.2 UI 线程踩过的卡顿主线程抢锁时界面直接无响应UI 线程主线程是所有界面事件的响应者它在锁上等上哪怕几十毫秒用户都能感觉到卡顿。如果临界区里还有文件读写、网络请求那界面基本就是未响应状态。我做过一个错误示范为了让界面的列表实时展示后台数据直接在绘图代码里mutex.lock()从共享队列里取数据。结果后台线程同步执行的wait()导致锁持有时间有时到几百毫秒主线程画界面时拿不到锁整个窗口拖不动、按钮点不了最后用户直接告诉我程序死了。现在的做法是让数据层和显示层彻底解耦后台线程把结果写入共享内存并加锁保护写完立即释放UI 层用QTimer定时tryLock(5)拿不到锁就画上一帧实在需要立刻反应最新数据的场合改用信号槽排队投递快照而不是让 UI 直接锁后台的容器在 Qt 的世界里UI 线程要尽量避免主动抢锁。宁可数据晚一个刷新周期显示也不能让用户看着窗口卡死。4.3 阻塞锁和 QueuedConnection 的交叉影响最后说一个更容易忽略的组合接收线程阻塞在mutex.lock()上时它的事件循环完全不工作了。这意味着所有投递给它的QueuedConnection事件都积压着没有机会执行即使发送方后来释放了锁接收线程还会先处理积压的一堆旧事件然后才轮到新事件处理顺序又可能造成逻辑上的错位。有一种规避思路是把共享数据的访问收口到专门的工作线程其他线程一律通过信号槽请求由这个线程处理完再通过信号槽发回。这样锁只存在于这一个线程内部其他线程不需要碰锁事件循环也不会因为外部锁而卡住。代价是吞吐量不如直接裸锁高但换来的是稳定性和可排查性。如果你的项目对延迟要求不是极其苛刻我推荐试试这个模式。5. QReadWriteLock 与读写锁读多写少场景如何优化5.1 什么时候用读写锁如果某个全局配置表、路由表、词条表被大量线程频繁读取但几乎不更新那对所有读取都加同一把互斥锁有点浪费——读操作不会改变数据读和读之间其实可以并发。QReadWriteLock就是为这种读多写少场景设计的。它允许多个读者同时持有读锁但写锁是排他的写锁拿到时所有读锁都被挡住。QReadWriteLock rwLock; void readConfig(int key) { QReadLocker locker(rwLock); // 读锁多个读者可并发 // 读取 configMap_[key] ... } void updateConfig(int key, const QString newValue) { QWriteLocker locker(rwLock); // 写锁排他 configMap_[key] newValue; }在多线程高频率读取的场景里读写锁能带来实打实的性能提升。但要注意QReadWriteLock也有递归版本QReadWriteLock::Recursive不确定自己是否会重入之前保持默认非递归就好。5.2 读写锁两个隐藏毛病写者饥饿与读锁升级读写锁不是银弹它有两个常见毛病。第一个是写者饥饿。如果读请求一直源源不断地到来读锁总被占着写锁可能很长时间都拿不到最后更新配置的线程等得越来越久。有些实现里可以通过策略调优但QReadWriteLock不提供复杂的公平性配置。遇到这种持续高并发读的情况我建议评测一下是不是更该用无锁方案而不是硬撑读写锁。第二个坑是读锁升级写锁。有人这样写void updateIfNeeded(int key, const QString value) { QReadLocker locker(rwLock); // 先拿读锁 if (configMap_[key] ! value) { // 想在这里升级成写锁不行 } }Qt 的QReadWriteLock不支持把读锁原地升级为写锁。如果你在持有读锁的时候尝试获取写锁会发生死锁——因为写锁要等待所有读锁释放而自己就握着一把读锁不放。正确做法是先释放读锁再拿写锁然后重新检查条件。// 先短持有读锁判断 rwLock.lockForRead(); bool needUpdate (configMap_[key] ! value); rwLock.unlock(); if (needUpdate) { rwLock.lockForWrite(); // 再次检查因为释放读锁的间隙可能有别人改过 if (configMap_[key] ! value) { configMap_[key] value; } rwLock.unlock(); }这个重新检查不是可有可无的。释放读锁到拿到写锁之间窗口期里其他写者可能已经改了数据直接覆盖会丢掉那次更新。5.3 优化读写锁的更简单思路不可变数据与快照对很多业务场景与其用读写锁精细控制并发读不如直接把数据做成不可变快照。我参与的模拟项目里配置表更新频率大约是每小时一次读取频率是每毫秒几十次。这种情况下完全没必要让读者去抢锁更新线程把新配置构建成一份独立的对象用QAtomicPointer或std::atomicConfigData*切换当前配置的指针读者只管读当前指针指向的对象因为这份对象一旦发布就不会再变这样做的最大好处是读者路径上完全没有锁性能接近无锁。代价是旧对象不能立刻释放需要引用计数或者延迟回收。如果你的平台没有实现延迟回收机制可以退而求其次只让写线程加锁读线程通过原子指针拿当前版本。这个模式比普通的读写锁更适用于配置极低频更新、读取极高频的场景。6. QMutex、std::mutex 怎么选以及我对锁粒度的一点体会6.1 同一个项目里锁 API 该如何选择很多 Qt 新人会纠结项目里既有 Qt 代码又有标准 C到底用QMutex还是std::mutex维度QMutexstd::mutex归属Qt 框架C 标准库核心用法QMutexLockerRAIIstd::lock_guard/unique_lockRAII与 Qt 容器互操作配合好也能用但要自己负责语义调试工具可在 Qt Creator 里查看锁状态需要标准库调试工具性能足够日常使用同样足够我的选择标准很简单整个数据结构的生命周期由 Qt 管理、代码风格也是 Qt 为主就用QMutex纯算法库、模板库或者需要跨框架复用的模块就用std::mutex。两边混用其实没什么问题只要不要在同一把锁上一会儿用lock_guard一会儿用QMutexLocker就行那会让代码很难维护。还有一个实践建议如果你用std::mutex又想要 Qt 的调试便利可以把std::lock_guard的变量命名成带_lock前缀便于检索。Qt 的QMutexLocker在 Creator 的 Debug 视图中能看到锁的owner thread状态排查死锁时比裸看代码省很多力气。6.2 锁粒度一把大锁 vs 多把小锁锁粒度就是一把锁管多少数据资源的问题。很多人一谈到并发优化就想着细粒度锁把原来的一个大锁拆成十个不同的小锁试图提高并发度。但每个小锁都增加了一条加锁路径也就增加了一种死锁组合可能。我之前在一段缓存代码里拆了三个锁一个管索引、一个管数据块、一个管引用计数。结果某次逻辑变更里A 线程拿数据锁等索引锁B 线程拿索引锁等数据锁ABBA 重演了一次。现在我的原则是默认一把锁保护一组强相关的数据只有确认某个字段被高频访问且确实拖累性能时才考虑拆分。并且拆分前先问三个问题拆了之后有没有线程会同时拿两把新锁加锁顺序在所有路径上是否一致有没有可能用原子变量替代其中一把锁很多场景里最优雅的做法根本不用拆锁而是把临界区缩到只有几行代码然后让QMutexLocker在那个小作用域里快速进出。6.3 我的几条实操习惯直接抄就行写 Qt 多线程这些年下面的习惯帮我避掉了大多数锁相关的事故所有访问共享数据的函数里第一行就是QMutexLocker locker(mutex_)不用裸的lock()/unlock()除非需要对超时做特殊处理。持锁时不加QThread::wait()、不耗时的 I/O、不发跨线程信号。数据和信号分开走先拷贝、再释放锁、再 emit。在涉及多个锁的地方用注释把加锁顺序写下来。比如// 加锁顺序: m_configMutex - m_dataMutex。这个注释救过我两次。能用 QMutex 解决的不因为追求性能直接上无锁结构。无锁代码的正确性证明比死锁更难普通业务不值得冒这个险。每个锁的声明处都写清楚它保护哪些变量。代码过几个月再看这份注释比什么文档都有用它直接回答了这个锁为什么不加在别处。说到最后还有一个调试习惯值得分享遇到死锁不要急着猜先把所有线程此刻的调用栈打出来看看。Qt Creator 调试器底部有线程视图能看到每个线程停在哪个函数、加了什么锁。哪两个线程互相等一眼就能认出来。先有事实再有推理比加个锁试试高效得多。我自己的体会是并发编程里 90% 的问题都不是锁不够快而是锁用得不对。把QMutex和QMutexLocker的语义、死锁的形成条件、以及 Qt 事件循环与锁的交互搞明白你就能躲过大多数让人失眠的线上事故。这个系列下一篇文章我打算继续写 Qt 信号槽的线程语义和moveToThread的实践细节如果你也在这些坑里欢迎一起交流。