3. 线程同步基础:竞态条件与QMutex

各位同学,今天我们来聊聊多线程编程里最让人头疼的问题——竞态条件。说实话,我刚开始接触多线程时,总觉得这玩意儿离我很远。直到有一次,我写了一个简单的计数器程序,两个线程同时累加,结果跑出来数字总是不对……嗯,从那以后,我再也不敢小看线程同步了。

3.1 什么是竞态条件?

竞态条件,说白了就是多个线程同时访问同一份数据,而且至少有一个线程在写。这时候,最终结果取决于线程执行的「时序」——谁先谁后,结果可能完全不同。

我举个例子你就明白了。假设你有一个全局变量 counter = 0,两个线程各自执行 counter++ 1000次。你猜结果是多少?理论上应该是2000,对吧?但实际跑起来,可能只有1500,甚至更少。

为什么会这样?因为 counter++ 不是原子操作。它实际上分三步:

  1. 从内存读取 counter 的值到寄存器
  2. 在寄存器里加1
  3. 把新值写回内存

如果线程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 知识体系总览

下面这张图是我自己画的,帮你理清本章的知识脉络:

线程同步基础:知识体系 线程同步 竞态条件 (Race Condition) 解决方案:互斥锁 QMutex 手动 lock() / unlock() QMutexLocker RAII 自动加锁/解锁 核心原则:保护共享数据,避免多个线程同时写入

3.5 避坑指南

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

  • 锁的粒度要合适:锁的范围太大,性能差;范围太小,可能保护不完整。我一般遵循「只锁必要的代码」原则。
  • 小心死锁:如果两个线程互相等待对方释放锁,就会死锁。比如线程A持有锁1,等待锁2;线程B持有锁2,等待锁1。解决办法是:尽量只用一个锁,或者按固定顺序加锁。
  • 不要锁住 UI 线程:在主线程里加锁等待,会导致界面卡死。我建议把耗时操作放到工作线程,UI 线程只负责更新界面。
  • QMutexLocker 不是万能的:它只解决加锁/解锁的问题,不解决逻辑错误。比如你锁住了代码,但业务逻辑本身有 bug,那该错还是错。

好了,这一章的内容就到这里。记住一句话:多线程编程,同步是常态,竞态是魔鬼。用好 QMutex 和 QMutexLocker,你的多线程程序就能稳如泰山。

本章小结:

  • 竞态条件:多个线程并发访问共享数据导致结果不确定
  • QMutex:提供 lock() 和 unlock() 方法保护临界区
  • QMutexLocker:RAII 风格,自动加锁解锁,推荐使用
  • 核心原则:保护共享数据,避免同时写入

可可精品资料库