4. 线程同步之QMutex:保护共享数据,防止竞态条件

多线程编程里,最让人头疼的问题是什么?我个人觉得,不是线程创建,也不是线程通信,而是——竞态条件。

说白了,就是多个线程同时访问同一块数据,你改一下,我改一下,最后数据乱成一锅粥。我在项目中遇到过好几次这种问题,调试起来那叫一个痛苦。今天我们就来聊聊Qt里解决这个问题的利器——QMutex。

4.1 什么是竞态条件?

先看一个简单的例子。假设我们有一个全局计数器,两个线程分别对它加1。你可能会想,这有什么难的?加1而已嘛。

但实际运行起来,结果往往不是2。有时候是1,有时候是2,甚至可能是0。为什么会这样?

因为 counter++ 这个操作,在CPU层面其实是三步:

  1. 读取 counter 的值到寄存器
  2. 寄存器里加1
  3. 把结果写回 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 知识体系总览

下面这张图总结了本章的核心知识点,你可以对照着回顾一下:

QMutex 线程同步知识体系 QMutex 互斥量 竞态条件 lock() / unlock() QMutexLocker 临界区要小(快进快出) Recursive 递归锁 死锁预防(锁顺序) 核心原则:保护共享数据,最小化临界区 避免死锁,优先使用 QMutexLocker

4.7 实战建议

最后,分享几个我在实际项目中的经验:

  • 能用 QMutexLocker 就别手动加解锁。这是最安全的做法,能避免90%的锁相关bug。
  • 锁的粒度要细。如果多个数据可以独立保护,就分别用不同的 mutex,不要一把大锁锁所有。
  • 注意锁的顺序。如果必须获取多个锁,所有线程都要按照相同的顺序来获取。
  • 考虑用 QReadWriteLock。如果读操作远多于写操作,读写锁比互斥锁效率更高。这个我们下一章会详细讲。

小技巧:调试多线程问题时,可以在关键位置加 qDebug() 输出当前线程ID和锁状态。虽然会影响性能,但能帮你快速定位问题。我经常用这个方法来排查死锁。

好了,QMutex 的内容就讲到这里。记住一句话:共享数据要保护,临界区域要最小。掌握了这个原则,多线程编程就成功了一半。

可可精品资料库