3. 线程同步基础:QMutex基础用法、QMutexLocker自动解锁、死锁的产生与预防、线程安全队列实现

多线程编程里,最让人头疼的问题是什么?我个人觉得,不是线程创建,也不是任务调度,而是——数据打架。

两个线程同时往一个变量里写数据,结果谁也不知道最终值是多少。这就是典型的竞态条件。解决它的核心手段,就是线程同步。今天咱们就从最基础的 QMutex 开始,一步步把同步这件事聊透。

3.1 QMutex 基础用法

QMutex 是 Qt 提供的最基本的互斥锁。它的作用很简单:同一时刻,只允许一个线程访问被保护的资源。

用法其实就三个步骤:

  1. 定义一个 QMutex 对象
  2. 在访问共享资源前调用 lock()
  3. 访问结束后调用 unlock()

看个例子:

// 全局变量
QMutex mutex;
int sharedCounter = 0;

void workerThread() {
    for (int i = 0; i < 1000; ++i) {
        mutex.lock();
        ++sharedCounter;   // 临界区
        mutex.unlock();
    }
}

嗯,这里要注意:lock() 和 unlock() 必须成对出现。少一个,另一个线程就永远进不去。多一个,程序直接崩溃。

⚠️ 常见坑: 如果临界区代码抛出异常,unlock() 就不会被执行。锁就永远解不开了。这就是为什么我们不推荐裸用 lock/unlock。

3.2 QMutexLocker 自动解锁

为了解决上面那个问题,Qt 提供了 QMutexLocker。它是个 RAII 封装类——构造时自动 lock,析构时自动 unlock。

说白了,你只管在临界区开头声明一个 QMutexLocker,剩下的交给它。哪怕中间 return 了、抛异常了,锁也会被正确释放。

void safeWorker() {
    QMutexLocker locker(&mutex);   // 自动 lock
    ++sharedCounter;
    // 函数结束时,locker 析构,自动 unlock
}

我个人习惯是:只要用到 QMutex,就一定配合 QMutexLocker。裸写 lock/unlock 的代码,我基本不写。为什么?因为我在项目中吃过亏——有一次临界区里调了一个第三方函数,那个函数内部抛了异常,结果锁没释放,整个界面卡死了。从那以后,我就只用 QMutexLocker。

💡 小技巧: QMutexLocker 还提供了 relock() 和 unlock() 方法,可以在需要时手动控制锁的粒度。但大多数场景下,让它自动管理就够了。

3.3 死锁的产生与预防

死锁,说白了就是两个线程互相等对方释放锁。你拿着锁 A 等锁 B,我拿着锁 B 等锁 A,谁都不让,程序就卡死了。

举个例子:

QMutex mutexA, mutexB;

void thread1() {
    QMutexLocker lockerA(&mutexA);
    // ... 做一些事
    QMutexLocker lockerB(&mutexB);  // 等 mutexB
}

void thread2() {
    QMutexLocker lockerB(&mutexB);
    // ... 做一些事
    QMutexLocker lockerA(&mutexA);  // 等 mutexA
}

如果 thread1 拿到了 mutexA,thread2 拿到了 mutexB,那两边就永远卡住了。

怎么预防?我总结了三条经验:

  • 固定加锁顺序:所有线程都按同样的顺序加锁。比如先 A 后 B,就不会出现循环等待。
  • 使用 tryLock:tryLock() 不会阻塞,如果拿不到锁就返回 false。可以避免死等。
  • 缩小锁范围:锁的粒度越小,死锁概率越低。别把整个函数都锁住。
🔑 核心原则: 死锁的四个必要条件(互斥、持有并等待、不可剥夺、循环等待)只要破坏一个,死锁就不会发生。最实用的就是破坏「循环等待」——统一加锁顺序。

3.4 线程安全队列实现

讲完了基础,咱们来点实战。实现一个线程安全的队列,这是多线程编程里最常见的需求之一。

思路很简单:用 QMutex 保护队列的 push 和 pop 操作。但要注意,pop 时如果队列为空,不能让线程空转,得让它等待。这时候就需要 QWaitCondition 了。

#include <QQueue>
#include <QMutex>
#include <QWaitCondition>

template<typename T>
class ThreadSafeQueue {
public:
    void push(const T &value) {
        QMutexLocker locker(&mutex);
        queue.enqueue(value);
        cond.wakeOne();  // 唤醒一个等待的线程
    }

    T pop() {
        QMutexLocker locker(&mutex);
        while (queue.isEmpty()) {
            cond.wait(&mutex);  // 等待,并释放锁
        }
        return queue.dequeue();
    }

private:
    QQueue<T> queue;
    QMutex mutex;
    QWaitCondition cond;
};

这里有个细节:cond.wait(&mutex) 会自动释放锁,让其他线程能 push 数据。被唤醒后,它会重新获取锁,然后继续执行。这个机制非常巧妙,避免了忙等待。

我曾经在一个音频采集项目里用过这个队列。主线程从麦克风读数据,push 到队列;工作线程从队列 pop 数据做处理。运行了几个月,没出过一次竞态问题。嗯,这就是同步的魅力。

💡 扩展思考: 如果想让队列支持超时等待,可以用 cond.wait(&mutex, timeout)。返回 true 表示被唤醒,false 表示超时。这在 UI 线程中特别有用,可以避免界面卡死。

3.5 本章知识体系

下面这张图,帮你把本章的核心逻辑串起来:

线程同步基础 · 知识体系 线程同步 QMutex 基础用法 QMutexLocker 自动解锁 死锁产生与预防 固定加锁顺序 tryLock 避免死等 缩小锁范围 线程安全队列 QMutex + QWaitCondition

从图中可以看到,线程同步的核心就是锁 + 条件变量。QMutex 负责互斥,QWaitCondition 负责等待与唤醒。两者结合,就能写出高效且安全的并发代码。

好了,这一章就到这里。记住一句话:锁是工具,不是目的。我们的目标不是用锁,而是让数据在并发环境下安全、高效地流动。

📌 本章要点回顾:
  • QMutex 提供基本的互斥访问,但必须配合 QMutexLocker 使用
  • QMutexLocker 是 RAII 封装,自动管理锁的获取与释放
  • 死锁的预防核心是统一加锁顺序
  • 线程安全队列 = QMutex + QWaitCondition,实现高效的生产者-消费者模式

可可精品资料库