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 生产者消费者模型:实战整合
把上面三个原语结合起来,就能构建一个健壮的生产者-消费者模型。我画了一张图帮你理解:
下面是完整的代码实现:
#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 已经帮我们封装好了,直接用就行。