3. 线程同步基础:竞态条件与QMutex
各位同学,今天我们来聊聊多线程编程里最让人头疼的问题——竞态条件。说实话,我刚开始接触多线程时,总觉得这玩意儿离我很远。直到有一次,我写了一个简单的计数器程序,两个线程同时累加,结果跑出来数字总是不对……嗯,从那以后,我再也不敢小看线程同步了。
3.1 什么是竞态条件?
竞态条件,说白了就是多个线程同时访问同一份数据,而且至少有一个线程在写。这时候,最终结果取决于线程执行的「时序」——谁先谁后,结果可能完全不同。
我举个例子你就明白了。假设你有一个全局变量 counter = 0,两个线程各自执行 counter++ 1000次。你猜结果是多少?理论上应该是2000,对吧?但实际跑起来,可能只有1500,甚至更少。
为什么会这样?因为 counter++ 不是原子操作。它实际上分三步:
- 从内存读取 counter 的值到寄存器
- 在寄存器里加1
- 把新值写回内存
如果线程A刚读完值(比如0),还没来得及写回去,线程B也读到了0。然后两个线程各自加1,都写回1。你看,明明加了两次,结果只增加了1。这就是典型的竞态条件。
核心概念:竞态条件是指多个线程并发访问共享数据,且最终结果依赖于线程执行顺序的不确定性。这是多线程编程中最常见的bug来源。
3.2 QMutex:最基础的同步工具
解决竞态条件,最直接的办法就是加锁。Qt提供了 QMutex 类,它的作用就像一把「门锁」——只有拿到钥匙的线程才能进入临界区,其他线程必须排队等待。
我个人习惯把QMutex想象成厕所的门锁。你进去之后把门锁上,别人就只能在外面等着。等你出来解锁了,下一个人才能进去。虽然排队会慢一点,但至少不会出现两个人同时用厕所的尴尬场面。
基本用法
#include <QMutex>
#include <QThread>
QMutex mutex; // 全局互斥锁
int counter = 0;
void Worker::run()
{
for (int i = 0; i < 1000; ++i) {
mutex.lock(); // 加锁
counter++; // 临界区
mutex.unlock(); // 解锁
}
}
这样改完之后,counter 的结果就稳定在2000了。代价是什么?性能下降。因为每次加锁解锁都有开销,而且线程之间要互相等待。
注意:千万不要忘记 unlock()!我曾经在一个项目里,因为某个分支提前 return 了,忘记解锁,结果整个程序死锁了。排查了整整一个下午……从那以后,我养成了用 QMutexLocker 的习惯。
3.3 QMutexLocker:自动解锁的救星
手动 lock/unlock 容易出错,尤其是当函数里有多个 return 路径,或者可能抛出异常时。Qt 提供了 QMutexLocker,它利用 RAII(资源获取即初始化)机制,在构造时加锁,析构时自动解锁。
说白了,你只管在需要的地方声明一个 QMutexLocker 对象,剩下的交给它。哪怕函数中间 return 了,或者抛异常了,QMutexLocker 的析构函数也会保证解锁。
#include <QMutex>
#include <QMutexLocker>
QMutex mutex;
int counter = 0;
void Worker::run()
{
for (int i = 0; i < 1000; ++i) {
QMutexLocker locker(&mutex); // 构造时自动加锁
counter++; // 临界区
// 离开作用域时,locker 自动析构,解锁
}
}
你看,代码简洁多了。而且不用担心忘记解锁。我个人强烈建议:能用 QMutexLocker 就别手动 lock/unlock。这不是偷懒,是降低出错概率。
小技巧:QMutexLocker 可以配合作用域使用。如果你只想保护某几行代码,可以加一对花括号 {} 把临界区包起来,让 locker 在离开这个作用域时自动解锁。
3.4 知识体系总览
下面这张图是我自己画的,帮你理清本章的知识脉络:
3.5 避坑指南
最后,分享几个我踩过的坑:
- 锁的粒度要合适:锁的范围太大,性能差;范围太小,可能保护不完整。我一般遵循「只锁必要的代码」原则。
- 小心死锁:如果两个线程互相等待对方释放锁,就会死锁。比如线程A持有锁1,等待锁2;线程B持有锁2,等待锁1。解决办法是:尽量只用一个锁,或者按固定顺序加锁。
- 不要锁住 UI 线程:在主线程里加锁等待,会导致界面卡死。我建议把耗时操作放到工作线程,UI 线程只负责更新界面。
- QMutexLocker 不是万能的:它只解决加锁/解锁的问题,不解决逻辑错误。比如你锁住了代码,但业务逻辑本身有 bug,那该错还是错。
好了,这一章的内容就到这里。记住一句话:多线程编程,同步是常态,竞态是魔鬼。用好 QMutex 和 QMutexLocker,你的多线程程序就能稳如泰山。
本章小结:
- 竞态条件:多个线程并发访问共享数据导致结果不确定
- QMutex:提供 lock() 和 unlock() 方法保护临界区
- QMutexLocker:RAII 风格,自动加锁解锁,推荐使用
- 核心原则:保护共享数据,避免同时写入