4. 高级同步原语:QReadWriteLock、QSemaphore、QWaitCondition

多线程编程里,锁是个绕不开的话题。但说实话,一把锁打天下的做法,在性能敏感的场景下往往行不通。我早年做图像处理引擎时,就吃过这个亏——多个读线程抢一把互斥锁,CPU占用率上去了,吞吐量却纹丝不动。

后来我意识到,读操作和写操作对资源的需求完全不同。读操作可以并行,写操作必须独占。Qt 提供的 QReadWriteLock 就是专门解决这个问题的。

4.1 QReadWriteLock:读写分离的智慧

说白了,QReadWriteLock 允许多个线程同时读,但写的时候只允许一个线程进入,并且读线程也得等着。

核心规则:

  • 任意线程都可以加读锁(lockForRead),多个读锁可以共存
  • 写锁(lockForWrite)必须独占,等所有读锁释放后才能拿到
  • 写锁持有期间,任何读锁申请都会被阻塞

我习惯这样用:

QReadWriteLock rwLock;
QByteArray sharedData;

// 读线程
void readerThread() {
    rwLock.lockForRead();
    // 安全读取 sharedData
    qDebug() << "读取数据:" << sharedData;
    rwLock.unlock();
}

// 写线程
void writerThread(const QByteArray &newData) {
    rwLock.lockForWrite();
    sharedData = newData;
    qDebug() << "写入数据完成";
    rwLock.unlock();
}

嗯,这里要注意:千万别在持有读锁的时候去申请写锁,否则会死锁。我曾经在代码审查时抓到过这种 bug,排查起来特别头疼。

我的经验:如果读操作远多于写操作(比如 10:1 以上),用 QReadWriteLock 替换 QMutex,性能提升非常明显。我在一个配置管理模块里试过,吞吐量直接翻倍。

4.2 QSemaphore:信号量的妙用

信号量本质上是一个计数器。它不像互斥锁那样只允许一个线程进入,而是允许固定数量的线程同时访问资源。

举个例子,假设系统有 4 个数据库连接可用:

QSemaphore dbConnections(4);  // 初始可用连接数

void processRequest() {
    dbConnections.acquire();  // 获取一个连接,如果没有则阻塞
    // 使用数据库连接...
    dbConnections.release();  // 释放连接
}

你想想看,如果不用信号量,你得手动维护一个连接池队列,还要处理各种边界条件。信号量把这些脏活都封装好了。

关键区别:

  • QMutex:一次只允许一个线程
  • QSemaphore:允许 N 个线程同时访问
  • QReadWriteLock:读多写少场景下的优化方案

我在项目中遇到过一种情况:用信号量控制生产者-消费者模型中的缓冲区空位。生产者生产一个数据,就释放一个空位;消费者消费一个数据,就占用一个空位。配合 QWaitCondition 使用,效果非常好。

4.3 QWaitCondition:条件变量的威力

条件变量解决的是等待某个条件成立的问题。比如,消费者线程需要等队列里有数据才能处理,如果队列为空,它不应该空转轮询,而应该挂起等待。

我刚开始学多线程时,总喜欢用 while(队列为空) { QThread::msleep(10); } 这种轮询方式。后来发现,这简直是 CPU 资源的噩梦。

正确的做法是用 QWaitCondition:

QMutex mutex;
QWaitCondition bufferNotEmpty;
QQueue<int> buffer;

// 生产者
void producer() {
    mutex.lock();
    buffer.enqueue(42);
    qDebug() << "生产了一个数据";
    bufferNotEmpty.wakeOne();  // 唤醒一个等待的消费者
    mutex.unlock();
}

// 消费者
void consumer() {
    mutex.lock();
    while (buffer.isEmpty()) {
        bufferNotEmpty.wait(&mutex);  // 自动解锁并等待
    }
    int data = buffer.dequeue();
    qDebug() << "消费了数据:" << data;
    mutex.unlock();
}

注意:wait() 必须在 mutex 已经锁定的情况下调用。它会自动释放互斥锁,让其他线程有机会修改条件。当被唤醒后,它会重新获取锁再返回。

这里有个坑:虚假唤醒。操作系统可能在没有调用 wakeOne() 或 wakeAll() 的情况下唤醒等待线程。所以,wait() 返回后一定要用 while 循环重新检查条件,而不是用 if。

4.4 生产者消费者模型:实战整合

把上面三个原语结合起来,就能构建一个健壮的生产者-消费者模型。我画了一张图帮你理解:

生产者线程 消费者线程 共享缓冲区 QQueue<int> buffer 最大容量: 100 enqueue dequeue QSemaphore freeSlots(100) QSemaphore usedSlots(0) QMutex mutex QWaitCondition cond QReadWriteLock rwLock (可选,用于读多写少) 生产者-消费者模型:信号量控制容量,互斥锁保护数据,条件变量避免轮询

下面是完整的代码实现:

#include <QCoreApplication>
#include <QThread>
#include <QMutex>
#include <QWaitCondition>
#include <QSemaphore>
#include <QQueue>
#include <QDebug>

class Buffer {
public:
    static const int MAX_SIZE = 100;
    
    Buffer() : freeSlots(MAX_SIZE), usedSlots(0) {}
    
    void produce(int value) {
        freeSlots.acquire();  // 等待空位
        mutex.lock();
        queue.enqueue(value);
        qDebug() << "生产:" << value << "当前大小:" << queue.size();
        mutex.unlock();
        usedSlots.release();  // 增加已用计数
    }
    
    int consume() {
        usedSlots.acquire();  // 等待有数据
        mutex.lock();
        int value = queue.dequeue();
        qDebug() << "消费:" << value << "当前大小:" << queue.size();
        mutex.unlock();
        freeSlots.release();  // 释放空位
        return value;
    }
    
private:
    QQueue<int> queue;
    QMutex mutex;
    QSemaphore freeSlots;
    QSemaphore usedSlots;
};

// 使用示例
int main(int argc, char *argv[]) {
    QCoreApplication app(argc, argv);
    
    Buffer buffer;
    
    // 启动生产者线程
    QThread::create([&buffer]() {
        for (int i = 0; i < 1000; ++i) {
            buffer.produce(i);
            QThread::msleep(10);  // 模拟生产耗时
        }
    })->start();
    
    // 启动消费者线程
    QThread::create([&buffer]() {
        for (int i = 0; i < 1000; ++i) {
            int value = buffer.consume();
            QThread::msleep(15);  // 模拟消费耗时
        }
    })->start();
    
    return app.exec();
}

我的建议:实际项目中,建议把 QSemaphore 和 QWaitCondition 结合使用。信号量控制容量,条件变量处理特殊情况(比如缓冲区满时通知消费者快点消费)。这样既保证了吞吐量,又避免了死锁。

4.5 避坑指南

最后,分享几个我踩过的坑:

  • 不要混用不同的同步原语保护同一份数据。要么全用互斥锁,要么全用读写锁,混用会导致逻辑混乱。
  • 信号量的 acquire 和 release 必须成对出现。我曾经在异常处理路径上忘了 release,结果信号量慢慢耗尽,程序卡死。
  • 条件变量的 wait 一定要在循环里调用。虚假唤醒是真实存在的,不是理论问题。
  • 读写锁不是万能的。如果写操作很频繁,读写锁的开销可能比互斥锁还大。我一般只在读操作占比超过 80% 时才用。

嗯,这些原语用好了,多线程编程会轻松很多。记住一个原则:能用高级原语解决的问题,就别自己造轮子。Qt 已经帮我们封装好了,直接用就行。

可可精品资料库