4. 线程同步之QMutex:保护共享数据,防止竞态条件
多线程编程里,最让人头疼的问题是什么?我个人觉得,不是线程创建,也不是线程通信,而是——竞态条件。
说白了,就是多个线程同时访问同一块数据,你改一下,我改一下,最后数据乱成一锅粥。我在项目中遇到过好几次这种问题,调试起来那叫一个痛苦。今天我们就来聊聊Qt里解决这个问题的利器——QMutex。
4.1 什么是竞态条件?
先看一个简单的例子。假设我们有一个全局计数器,两个线程分别对它加1。你可能会想,这有什么难的?加1而已嘛。
但实际运行起来,结果往往不是2。有时候是1,有时候是2,甚至可能是0。为什么会这样?
因为 counter++ 这个操作,在CPU层面其实是三步:
- 读取 counter 的值到寄存器
- 寄存器里加1
- 把结果写回 counter
如果两个线程同时执行这三步,就可能出现:线程A读了0,线程B也读了0,然后各自加1,都写回1。结果就是加了两次,counter还是1。这就是典型的竞态条件。
核心概念:竞态条件是指多个线程并发访问共享数据,且最终结果依赖于线程执行的时序。这种bug非常隐蔽,不是每次都能复现,但一旦出现,后果可能很严重。
4.2 QMutex 的基本用法
QMutex 是 Qt 提供的互斥量。它的作用很简单:保证同一时刻只有一个线程能访问被保护的代码段。
用法也很直接:
#include <QMutex>
#include <QThread>
QMutex mutex;
int counter = 0;
void increment() {
mutex.lock(); // 加锁
counter++; // 临界区
mutex.unlock(); // 解锁
}
嗯,这里要注意:lock() 和 unlock() 必须成对出现。如果忘记解锁,其他线程就会永远卡在 lock() 那里,造成死锁。
我个人习惯用 QMutexLocker 来替代手动加解锁,这样更安全:
void increment() {
QMutexLocker locker(&mutex); // 构造时自动加锁
counter++; // 临界区
// 离开作用域时自动解锁
}
你看,代码简洁多了,而且不用担心忘记解锁。就算函数中间提前 return,QMutexLocker 的析构函数也会自动释放锁。这个技巧我在项目里一直在用,强烈推荐。
4.3 临界区的设计原则
有了 QMutex,是不是就可以随便用了?当然不是。我见过不少新手把整个函数都锁起来,结果多线程程序跑得比单线程还慢。
临界区要尽量小。只锁那些真正需要保护的代码,不要锁无关的操作。比如:
// ❌ 不好的做法:锁的范围太大
void processData(QList<int> &data) {
QMutexLocker locker(&mutex);
// 这里可能有一些耗时的计算
int sum = 0;
for (int i = 0; i < 1000000; ++i) {
sum += i;
}
// 真正需要保护的操作
data.append(sum);
}
// ✅ 好的做法:只锁必要的部分
void processData(QList<int> &data) {
int sum = 0;
for (int i = 0; i < 1000000; ++i) {
sum += i;
}
QMutexLocker locker(&mutex);
data.append(sum);
}
你想想看,第一个例子把耗时的计算也锁住了,其他线程只能干等着。这完全违背了多线程的初衷。
经验之谈:临界区的设计原则是「快进快出」。加锁后尽快完成操作,然后立即解锁。不要在锁里做IO操作、网络请求、复杂计算等耗时任务。
4.4 QMutex 的递归锁
有时候,同一个线程可能会多次尝试获取同一个锁。比如一个函数调用了另一个函数,两个函数都用了同一个 mutex。这种情况下,普通的 QMutex 会导致死锁——因为线程自己把自己锁住了。
Qt 提供了 QMutex::Recursive 模式来解决这个问题:
QMutex mutex(QMutex::Recursive);
void funcA() {
QMutexLocker locker(&mutex);
// 做一些操作
funcB(); // 这里又会尝试加锁
}
void funcB() {
QMutexLocker locker(&mutex); // 递归锁允许同一个线程再次加锁
// 继续操作
}
不过,我个人建议尽量少用递归锁。它往往意味着你的代码设计有问题——函数调用层次太深,或者职责划分不清。我在项目中遇到递归锁的场景,最后都通过重构代码解决了。
4.5 死锁的预防
死锁是多线程编程里最可怕的敌人。一旦发生,程序就像死了一样,没有任何响应。
最常见的死锁场景是:两个线程各自持有一个锁,同时等待对方释放另一个锁。
// 线程A
mutex1.lock();
mutex2.lock(); // 等待线程B释放mutex2
// 线程B
mutex2.lock();
mutex1.lock(); // 等待线程A释放mutex1
我曾经在一个网络服务器项目里遇到过这个问题。两个线程分别处理不同的数据流,结果因为锁的顺序不一致,服务器每隔几小时就卡死一次。排查了整整两天才找到原因。
避坑指南:我曾经因为锁顺序不一致,导致线上服务频繁死锁。后来定了一个规矩:所有线程获取多个锁时,必须按照相同的顺序。比如总是先锁mutex1再锁mutex2,这样就能避免循环等待。
4.6 知识体系总览
下面这张图总结了本章的核心知识点,你可以对照着回顾一下:
4.7 实战建议
最后,分享几个我在实际项目中的经验:
- 能用 QMutexLocker 就别手动加解锁。这是最安全的做法,能避免90%的锁相关bug。
- 锁的粒度要细。如果多个数据可以独立保护,就分别用不同的 mutex,不要一把大锁锁所有。
- 注意锁的顺序。如果必须获取多个锁,所有线程都要按照相同的顺序来获取。
- 考虑用 QReadWriteLock。如果读操作远多于写操作,读写锁比互斥锁效率更高。这个我们下一章会详细讲。
小技巧:调试多线程问题时,可以在关键位置加 qDebug() 输出当前线程ID和锁状态。虽然会影响性能,但能帮你快速定位问题。我经常用这个方法来排查死锁。
好了,QMutex 的内容就讲到这里。记住一句话:共享数据要保护,临界区域要最小。掌握了这个原则,多线程编程就成功了一半。
可可精品资料库