3、线程同步基础:QMutex、QMutexLocker的使用方法、临界区保护、死锁的成因与避免策略
在多线程程序中,多个线程并发访问共享数据时,如果不加任何约束,就会产生数据竞争(Data Race),导致程序行为不可预测。Qt 提供了丰富的线程同步原语,其中 QMutex 是最基础、最常用的互斥锁。本章将深入讲解 QMutex 与 QMutexLocker 的用法、临界区保护原则,以及死锁的成因与避免策略,帮助读者建立扎实的线程同步基础。
上图展示了本章的核心脉络:多个线程通过 QMutex 竞争进入临界区,而临界区保护不当或加锁顺序错误则可能引发死锁。下面我们逐一展开。
3.1 核心原理:为什么需要互斥锁
当两个线程同时执行 counter++ 这样的操作时,底层实际上对应三条 CPU 指令:读取内存到寄存器、寄存器加一、写回内存。如果线程 A 和线程 B 同时执行,可能出现如下交错:A 读取到 10,B 也读取到 10,A 写回 11,B 写回 11,最终结果丢失了一次更新。这种问题称为竞态条件(Race Condition)。
QMutex 提供了一种互斥(Mutual Exclusion)机制:任何时刻只允许一个线程持有锁,只有持有锁的线程才能进入临界区访问共享数据。其他线程必须阻塞等待,直到锁被释放。Qt 的 QMutex 在内部封装了平台相关的原子操作和等待队列,保证了线程安全性。
3.2 QMutex 与 QMutexLocker 的使用方法
3.2.1 QMutex 基本操作
QMutex 的核心 API 包括 lock()、unlock() 和 tryLock()。其中 lock() 是阻塞调用,如果锁已被其他线程持有,当前线程会一直等待;tryLock() 则立即返回,成功获取锁返回 true,否则返回 false,不会阻塞。
QMutex mutex;
int sharedCounter = 0;
void worker() {
mutex.lock();
sharedCounter++; // 临界区
mutex.unlock();
}
这种手动 lock/unlock 的方式存在隐患:如果临界区中发生异常或提前 return,unlock 可能不会执行,导致死锁。因此 Qt 提供了 QMutexLocker,它利用 RAII(资源获取即初始化)机制,在构造时自动 lock,在析构时自动 unlock。
3.2.2 QMutexLocker 的优雅用法
void safeWorker() {
QMutexLocker locker(&mutex); // 构造时自动 lock
sharedCounter++; // 临界区
if (sharedCounter > 100) {
return; // 提前返回,locker 析构时自动 unlock
}
// 其他操作
} // 函数结束,locker 析构,自动 unlock
QMutexLocker 还支持 relock() 和 unlock() 的显式调用,但大多数场景下只需依赖其析构函数即可。使用 QMutexLocker 可以显著降低因忘记解锁或异常路径导致的死锁风险。
| 特性 | QMutex 手动管理 | QMutexLocker |
|---|---|---|
| 加锁方式 | 显式调用 lock() | 构造时自动加锁 |
| 解锁方式 | 显式调用 unlock() | 析构时自动解锁 |
| 异常安全 | 否,异常时可能死锁 | 是,栈展开时自动解锁 |
| 提前 return | 需手动在每条路径解锁 | 无需处理,自动解锁 |
| 代码可读性 | 较低,容易遗漏 | 高,清晰简洁 |
3.3 临界区保护的最佳实践
保护临界区不仅仅是加锁那么简单,还需要注意锁的粒度和锁的范围。锁的粒度是指锁保护的代码量,粒度太粗会降低并发性能,粒度太细则可能引入新的竞态条件。
3.3.1 锁粒度控制原则
- 最小化临界区:只将真正访问共享数据的代码放入锁内,避免在锁内执行耗时操作(如文件 IO、网络请求)。
- 避免锁内调用外部回调:如果临界区内调用其他对象的函数,而该函数又尝试获取同一把锁,就会造成死锁。
- 区分读写场景:如果读多写少,可以考虑使用 QReadWriteLock 替代 QMutex,允许多个读者并发访问。
3.3.2 共享数据的封装
更好的做法是将共享数据封装在一个类中,并确保所有对数据的访问都通过加锁的成员函数进行。这样可以将锁的细节隐藏在类内部,外部调用者无需关心同步问题。
class SafeCounter {
public:
void increment() {
QMutexLocker locker(&m_mutex);
m_value++;
}
int value() const {
QMutexLocker locker(&m_mutex);
return m_value;
}
private:
mutable QMutex m_mutex;
int m_value = 0;
};
3.4 死锁的成因与避免策略
死锁是指两个或多个线程互相等待对方释放资源,导致所有线程都无法继续执行的状态。死锁的产生必须同时满足四个必要条件:互斥条件、持有并等待、不可剥夺、循环等待。
3.4.1 典型死锁场景
最常见的死锁场景是交叉加锁:线程 A 持有锁 1,等待锁 2;线程 B 持有锁 2,等待锁 1。在 Qt 中,如果两个 QMutex 被不同线程以相反顺序加锁,就会触发死锁。
QMutex mutexA, mutexB;
void threadA() {
QMutexLocker lockA(&mutexA);
// 模拟耗时操作
QThread::msleep(10);
QMutexLocker lockB(&mutexB); // 等待锁 B
// 操作共享资源
}
void threadB() {
QMutexLocker lockB(&mutexB);
QThread::msleep(10);
QMutexLocker lockA(&mutexA); // 等待锁 A
// 操作共享资源
}
3.4.2 避免死锁的实用策略
- 固定加锁顺序:所有线程必须按照相同的顺序获取多把锁。例如,总是先锁 A 再锁 B,就不会出现循环等待。
- 使用 tryLock 超时机制:在尝试获取第二把锁时,使用
tryLock()并设置超时时间,如果获取失败则释放已持有的锁,稍后重试。 - 使用 QMutexLocker 的 unlock 提前释放:在不需要锁时立即释放,减少持有锁的时间。
- 避免在持有锁时调用未知代码:包括信号槽、虚函数、回调函数等,这些代码可能间接尝试获取其他锁。
3.5 案例拆解:银行转账中的死锁与解决
我们通过一个经典的银行转账案例来完整演示死锁的产生与解决。假设有两个账户对象,每个账户内部有一个 QMutex 保护余额,转账操作需要同时锁定两个账户。
3.5.1 初始实现与死锁复现
class Account {
public:
Account(double balance) : m_balance(balance) {}
void debit(double amount) { m_balance -= amount; }
void credit(double amount) { m_balance += amount; }
QMutex& mutex() { return m_mutex; }
private:
double m_balance;
QMutex m_mutex;
};
// 转账函数(有死锁风险)
void transfer(Account& from, Account& to, double amount) {
QMutexLocker lockFrom(&from.mutex());
QMutexLocker lockTo(&to.mutex());
from.debit(amount);
to.credit(amount);
}
当线程 1 执行 transfer(accA, accB, 100),线程 2 执行 transfer(accB, accA, 50) 时,线程 1 持有 accA 的锁等待 accB 的锁,线程 2 持有 accB 的锁等待 accA 的锁,死锁发生。
3.5.2 解决方案:按账户 ID 排序加锁
解决思路是打破循环等待条件。我们为每个账户分配一个唯一 ID,在转账时总是先锁 ID 较小的账户,再锁 ID 较大的账户。这样所有线程都按照相同的全局顺序加锁,就不会形成循环。
class Account {
public:
Account(int id, double balance) : m_id(id), m_balance(balance) {}
int id() const { return m_id; }
// ... 其他成员不变
private:
int m_id;
double m_balance;
QMutex m_mutex;
};
void safeTransfer(Account& from, Account& to, double amount) {
Account& first = (from.id() < to.id()) ? from : to;
Account& second = (from.id() < to.id()) ? to : from;
QMutexLocker lockFirst(&first.mutex());
QMutexLocker lockSecond(&second.mutex());
from.debit(amount);
to.credit(amount);
}
3.5.3 结果与复盘
通过固定加锁顺序,无论线程如何交错,都不会出现循环等待。该方案简单高效,但要求所有涉及多把锁的代码路径都必须遵守同一顺序。如果系统中有多个不同的锁集合,需要为每个集合定义全局唯一的排序规则。
| 方案 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|
| 固定顺序加锁 | 简单、无额外开销 | 需要全局规划锁顺序 | 锁数量少且关系明确 |
| tryLock + 回退 | 灵活,不要求全局顺序 | 可能产生饥饿,代码复杂 | 锁数量多,顺序难以统一 |
| 单一全局锁 | 安全 | 并发性能极差 | 共享资源极少 |
3.6 常见误区与总结复盘
在实际开发中,以下误区经常导致线程同步问题:
- 误区一:忘记解锁:手动 lock/unlock 时,如果中间有分支或异常,容易遗漏 unlock。解决方案是始终使用 QMutexLocker。
- 误区二:锁的粒度太大:在锁内执行耗时的 IO 操作,导致其他线程长时间阻塞,系统响应变慢。应尽量缩小临界区。
- 误区三:在锁内调用信号槽:如果信号槽的接收者尝试获取同一把锁,就会造成死锁。建议在锁外 emit 信号。
- 误区四:认为 QMutex 是递归的:默认 QMutex 是非递归的,同一线程重复 lock 会死锁。需要递归时使用 QRecursiveMutex。
- 误区五:忽略 tryLock 的返回值:tryLock 失败时表示锁不可用,必须处理失败分支,不能直接继续执行。