多线程能提高效率,但数据安全怎么办?
在上一篇文章中,我们学习了Qt的QThread多线程机制,知道如何创建工作线程,并将耗时任务放到其他线程中执行,避免GUI主线程长时间阻塞。
不过,随着线程数量增加,一个新的问题也随之出现**:如果多个线程同时修改同一份数据,程序还能保证结果正确吗?**
例如,两个线程同时对一个共享变量进行累加操作,明明每个线程都执行了指定次数,最终得到的结果却可能比预期小。这并不是线程没有正常运行,而是因为多个线程对共享数据的操作发生了竞争,导致部分更新丢失。
为了解决这类问题,我们需要引入线程同步机制。在Qt中,最常见的方式之一就是使用QMutex互斥锁,保证同一时刻只有一个线程进入受保护的临界区。
但锁也不是简单地调用lock()和unlock() 就万事大吉了。如果忘记解锁,或者程序在执行过程中提前返回,就可能引发死锁等问题。为此,Qt还提供了QMutexLocker,利用RAII思想自动管理锁的生命周期。除了普通互斥锁,Qt还提供了适合特定读多写少场景的QReadWriteLock,让多个读取线程能够并发访问共享数据。
本文将从线程安全问题入手,通过实际代码复现数据竞争,再逐步使用互斥锁、RAII守卫锁和读写锁解决问题,理解不同锁机制的使用方式及其适用场景。
目录
[1.1 线程安全基础](#1.1 线程安全基础)
[1.1.1 线程安全问题的根源:共享资源与并发访问](#1.1.1 线程安全问题的根源:共享资源与并发访问)
[1.2 QMutex互斥锁详解:从基本使用到线程安全实战](#1.2 QMutex互斥锁详解:从基本使用到线程安全实战)
[1.2.1 初步认识QMutex:如何使用互斥锁](#1.2.1 初步认识QMutex:如何使用互斥锁)
[1.2.2 QMutex核心API:加锁、解锁与尝试获取锁](#1.2.2 QMutex核心API:加锁、解锁与尝试获取锁)
[1.2.3 实战一:复现多线程访问共享数据的不安全问题](#1.2.3 实战一:复现多线程访问共享数据的不安全问题)
[1.2.4 实战二:使用QMutex解决共享数据竞争](#1.2.4 实战二:使用QMutex解决共享数据竞争)
[thread.h 修改后](#thread.h 修改后)
[thread.cpp 修改后](#thread.cpp 修改后)
[1.3 从手动管理到自动释放:QMutexLocker与RAII思想](#1.3 从手动管理到自动释放:QMutexLocker与RAII思想)
[1.3.1 手动lock()/unlock()的风险:遗漏解锁与异常退出](#1.3.1 手动lock()/unlock()的风险:遗漏解锁与异常退出)
[1.3.2 理解RAII:利用对象生命周期管理资源](#1.3.2 理解RAII:利用对象生命周期管理资源)
[1.3.3 QMutexLocker实战:自动加锁与解锁](#1.3.3 QMutexLocker实战:自动加锁与解锁)
[1.3.4 Qt锁与C++标准库锁:区别及混合使用注意事项](#1.3.4 Qt锁与C++标准库锁:区别及混合使用注意事项)
[1.4 QReadWriteLock读写锁:提升读多写少场景的并发能力](#1.4 QReadWriteLock读写锁:提升读多写少场景的并发能力)
[1.4.1 为什么需要读写锁:互斥锁与读写锁的适用场景](#1.4.1 为什么需要读写锁:互斥锁与读写锁的适用场景)
[1.4.2 读写锁的RAII守卫:QReadLocker与QWriteLocker](#1.4.2 读写锁的RAII守卫:QReadLocker与QWriteLocker)
[1.4.3 QReadWriteLock代码实战:并发读取与独占写入](#1.4.3 QReadWriteLock代码实战:并发读取与独占写入)
[1.5 核心知识回顾与常见面试问题](#1.5 核心知识回顾与常见面试问题)
一、Qt线程安全与锁机制:从数据竞争到同步保护
1.1 线程安全基础
1.1.1 线程安全问题的根源:共享资源与并发访问
多线程并发访问共享资源,线程什么时候被调度、谁先谁后,完全不可预测。
拿最简单的num++来说,它压根不是一步到位的原子操作,底层对应着好几条CPU指令。线程完全可能在指令中间被切走,另一个线程趁虚而入,两边同时读、同时写,数据竞争就这么发生了,最终算出来的结果,自然错得离谱。
解决思路,核心就一句:加锁,把并发访问硬生生掰成串行访问。
这把锁的来路,追到底层,其实是一脉相承的:
-
Linux原生:mutex互斥量,最原始的底子。
-
C++11标准库:std::mutex,对操作系统互斥量做了一层封装。
-
Qt:QMutex,同样封装操作系统的互斥量,类名遵循Q前缀的老规矩。
1.2 QMutex互斥锁详解:从基本使用到线程安全实战
1.2.1 初步认识QMutex:如何使用互斥锁
理论说再多,不如先看一段代码。下面这个例子,让两个线程同时对同一个共享变量做自增,用QMutex把临界区锁住:
cpp
#include <QCoreApplication>
#include <QMutex>
#include <QThread>
#include <QDebug>
QMutex mutex; // 全局互斥锁
int g_num = 0; // 共享资源
class Worker : public QThread
{
protected:
void run() override
{
for (int i = 0; i < 100000; ++i)
{
mutex.lock(); // 加锁,进入临界区
++g_num; // 临界区:操作共享资源
mutex.unlock(); // 解锁,离开临界区
}
}
};
int main(int argc, char *argv[])
{
QCoreApplication a(argc, argv);
Worker w1, w2;
w1.start();
w2.start();
w1.wait();
w2.wait();
qDebug() << "g_num =" << g_num; // 期望 200000
return a.exec();
}
两个Worker线程各自跑十万次自增,总数二十万。没有mutex护着,++g_num那几步指令随时可能被切换打断,结果大概率少一截;加上mutex.lock()和mutex.unlock()把临界区罩住,最终结果稳稳落在200000。
几个必须注意的点:
第一,锁的作用范围要尽量小。 只把真正碰共享资源的那几行包进去,别把整个循环体甚至整个函数都锁上。锁的范围越大,并发度越低,性能越差。
第二,lock()和unlock()必须成对出现。 加锁之后如果中途return、抛异常、或者走了别的分支,忘了unlock(),这把锁就永远锁死了,其他线程全得跟着陪葬。
**第三,unlock()必须由持有锁的线程调用。**谁锁的,谁解。别的线程跑来解锁,行为未定义,程序怎么崩的都说不准。
1.2.2 QMutex核心API:加锁、解锁与尝试获取锁
| 接口 | 说明 |
|---|---|
| void lock() | 加锁。锁已经被其他线程持有?调用线程就阻塞在这儿,一直等到把锁拿到手为止。 |
| void unlock() | 释放锁。必须由持有锁的线程调用,谁锁的谁解,不能越俎代庖。 |
| bool tryLock() | 尝试加锁,不阻塞。拿到返回true,拿不到立刻返回false,适合"抢不到就先干别的"的场景。 |
1.2.3 实战一:复现多线程访问共享数据的不安全问题
业务场景:两个QThread子线程,同时对同一个静态共享变量循环累加。
预期结果:每个线程循环50000次,最终结果 100000。不加锁,结果会小于100000,数据竞争当场现形。
thread.h
cpp
#ifndef THREAD_H
#define THREAD_H
#include <QThread>
class Thread : public QThread
{
Q_OBJECT
public:
Thread();
// 共享静态变量,多线程访问
static int num;
protected:
// 线程入口函数,重写 run
void run() override;
};
#endif // THREAD_H
thread.cpp
cpp
#include "thread.h"
// 静态成员必须在类外初始化
int Thread::num = 0;
Thread::Thread()
{
}
void Thread::run()
{
// 不加锁,并发修改共享变量,线程不安全
for (int i = 0; i < 50000; ++i)
{
num++;
}
}
widget.cpp
cpp
#include "widget.h"
#include "ui_widget.h"
#include "thread.h"
#include <QDebug>
Widget::Widget(QWidget *parent)
: QWidget(parent)
, ui(new Ui::Widget)
{
ui->setupUi(this);
Thread t1;
Thread t2;
t1.start();
t2.start();
// 主线程等待两个子线程执行完毕,再打印结果
// 不调用 wait 会直接打印未完成的值
t1.wait();
t2.wait();
qDebug() << Thread::num;
}
Widget::~Widget()
{
delete ui;
}
现象:输出结果小于100000。数据竞争,当场复现。
为什么少了?
num++看着只有一行,拆到底层,至少三条指令:从内存读num到寄存器,寄存器里加一,再写回内存。线程完全可能读到同一个旧值,各自加一,各自写回,最后只涨了一次。两个线程各跑五万次,本该十万,实际少掉的那一截,全是被这种"撞车"吞掉的。
这就是线程不安全的现场:代码没错,逻辑没错,错的是"没有锁,并发就不讲规矩"。
1.2.4 实战二:使用QMutex解决共享数据竞争
这里有个前提必须先说清楚:多线程要互斥,所有线程必须操作同一个锁对象。 如果每个线程自己揣着一把锁,各锁各的,那就等于没锁,互斥保护形同虚设。所以,锁对象定义成static,保证全局唯一。
thread.h 修改后
cpp
#ifndef THREAD_H
#define THREAD_H
#include <QThread>
#include <QMutex>
class Thread : public QThread
{
Q_OBJECT
public:
Thread();
static int num;
// 静态互斥锁对象,所有线程共用同一把锁
static QMutex mutex;
protected:
void run() override;
};
#endif // THREAD_H
thread.cpp 修改后
cpp
#include "thread.h"
int Thread::num = 0;
// 静态锁对象,类外定义初始化
QMutex Thread::mutex;
Thread::Thread()
{
}
void Thread::run()
{
for (int i = 0; i < 50000; ++i)
{
mutex.lock(); // 加锁
num++; // 临界区,访问共享资源
mutex.unlock(); // 解锁
}
}
运行结果,固定输出100000,线程安全问题就此消除。
改了哪两处,效果为什么这么大?
第一处,QMutex声明成static成员。这一手,把锁从"每个线程人手一把"变成了"全场共用一把"。两个线程抢的是同一把锁,谁先抢到谁进临界区,另一个只能在门外等着。互斥,这才算真正立起来。
第二处,run()里的num++被lock()和unlock()夹在了中间。从内存读、寄存器加一、写回内存,这三步指令被锁罩成了一个整体。一个线程在里面操作的时候,另一个线程根本插不进来。等它解锁,另一个才轮到进场。并发访问,就这么被硬生生掰成了串行访问。
num++本身不是原子的,但锁一包,对外看起来,它就是原子的了。锁的价值,全在这儿。
1.3 从手动管理到自动释放:QMutexLocker与RAII思想
1.3.1 手动lock()/unlock()的风险:遗漏解锁与异常退出
手动配对lock()和unlock(),看着简单,实则脆弱得很。稍不留神,锁就漏了。
最常见的两个翻车场景:
场景一,临界区里有个if分支,提前return 了。 函数一退,后面的unlock()压根执行不到。锁还攥在手里,人就走了。
场景二,临界区里的代码抛了异常。 程序流程直接跳转,unlock()同样被跳过,连声招呼都不打。
锁泄漏的后果很直接:其他线程永久阻塞在lock()上,眼巴巴等着,程序就这么卡死了。 一把没释放的锁,能把整个程序拖进死胡同。
1.3.2 理解RAII:利用对象生命周期管理资源
RAII的核心就一句话:资源获取即初始化,靠局部对象的生命周期,自动管理资源。
对象在栈上构造,资源就位;对象出了作用域,析构函数自动执行,资源释放。不用手动管,也管不着忘不忘。
C++11标准库给了std::lock_guard<std::mutex>,**栈上对象一构造就加锁,析构就解锁。**Qt这边对应的是QMutexLocker,功能等价,构造时接收锁指针,栈局部对象一出作用域,自动析构,自动unlock()。
异常也好,提前return也罢,只要对象离开作用域,析构函数一定被调用。锁,一定被释放。手动lock/unlock那两个坑,RAII一手全填了。
1.3.3 QMutexLocker实战:自动加锁与解锁
cpp
#include "thread.h"
#include <QMutexLocker>
int Thread::num = 0;
QMutex Thread::mutex;
Thread::Thread()
{
}
void Thread::run()
{
for (int i = 0; i < 50000; ++i)
{
// 局部栈对象,构造时 lock(&mutex)
QMutexLocker locker(&mutex);
num++;
// 无需手动 unlock;
// 本轮循环的大括号一结束,locker 这个局部对象就析构,自动调用 unlock
}
}
这段代码,把上一节手动lock/unlock的写法彻底换掉了。QMutexLocker locker(&mutex)一构造,锁就加上了;**等本轮循环的大括号走到头,locker出作用域,析构函数自动执行,锁跟着释放。**全程不用你手动解,也轮不到你忘。
最关键的一点:不管内部是return提前退出,还是抛了异常,只要离开当前作用域,局部locker对象一定析构,锁一定释放。 手动写法那两个坑,忘记解锁、异常跳过,被这一个栈对象,从根上堵死了。
1.3.4 Qt锁与C++标准库锁:区别及混合使用注意事项
QMutex和std::mutex,底层封装的都是操作系统原生的互斥API。技术上,混着用是能跑的。
但工程实践里,不推荐混用。
原因很实在:接口不统一,守卫类也不统一,QMutexLocker对std::lock_guard,两套写法来回切,维护成本蹭蹭往上涨。代码一长,很容易写出"锁错了对象"或者"该用A守卫的地方用了B"这类错误。统一用一套,规矩清楚,出了问题也好排查。
1.4 QReadWriteLock读写锁:提升读多写少场景的并发能力
1.4.1 为什么需要读写锁:互斥锁与读写锁的适用场景
有一种业务场景很典型:大量线程并发读,极少线程动手写。 读多写少,天平严重偏向读的一边。
这时候要是还拿QMutex去锁,问题就来了,它是把读写全部串行化,读操作和读操作之间也会互相阻塞。十个人想同时看一份数据,结果排成一队,一个一个进,并发性能白白浪费。
读写锁就是为这种场景量身定做的。它的规则很清楚:
| 组合 | 是否互斥 | 说明 |
|---|---|---|
| 读锁 vs 读锁 | 不互斥 | 多个线程可以同时拿读锁,并发读取,谁也不挡谁 |
| 读锁 vs 写锁 | 互斥 | 有人写的时候,读的人得等着 |
| 写锁 vs 写锁 | 互斥 | 同一时刻只允许一个线程写,写操作独占资源 |
一句话:读的时候大家一起来,写的时候一个人独占。 读多写少的场景下,并发度一下就上去了。
1.4.2 读写锁的RAII守卫:QReadLocker与QWriteLocker
跟QMutex配QMutexLocker一样,读写锁也配了两个守卫类,思路一脉相承,构造即加锁,析构即解锁,栈对象出作用域,锁自动释放:
| 守卫类 | 作用 |
|---|---|
| QReadLocker | 读锁的RAII守卫。构造时上读锁,析构时释放读锁。 |
| QWriteLocker | 写锁的RAII守卫。构造时上写锁,析构时释放写锁。 |
用法跟QMutexLocker一个套路,把它们往栈上一放,作用域一结束,锁就自动还回去了。手动lockForRead()/unlock()那套容易漏解锁的坑,一样被堵死。
1.4.3 QReadWriteLock代码实战:并发读取与独占写入
cpp
#include <QReadWriteLock>
QReadWriteLock rwLock;
// 读操作:多个线程可以并发进来
{
QReadLocker locker(&rwLock);
// 执行只读访问,读共享资源
} // 作用域一结束,读锁自动释放
// 写操作:独占访问,一次只放一个
{
QWriteLocker locker(&rwLock);
// 修改共享资源
} // 作用域一结束,写锁自动释放
这段代码,把读和写分成两个括号块,各用各的守卫。
读操作块里,QReadLocker一构造,读锁就上了。多个线程可以同时进这个块,各自读各自的,谁也不挡谁。块一结束,守卫析构,读锁自动释放。
写操作块里,QWriteLocker一构造,写锁就上了。这时候是独占,别的线程想读、想写,都得在门外等着。块一结束,写锁自动释放,下一轮竞争才重新开始。
QReadLocker和QWriteLocker,跟QMutexLocker是同一个套路,栈上对象,构造即加锁,析构即解锁。出作用域自动释放,不怕提前return,也不怕异常跳走。读多写少的场景里,这套组合把并发度拉满的同时,安全一点没丢。
1.5 核心知识回顾与常见面试问题
这一节的内容,几乎每个都是面试官爱问的点,值得反复咀嚼。
①锁,必须是同一把。
QMutex要真正起到互斥作用,前提是所有线程操作的是同一个锁实例。各拿各的锁,等于各锁各的,互斥形同虚设。static成员是最常用的实现方式,全局唯一,谁来了都用这一把。
②wait()不是可有可无的。
主线程调wait(),作用是阻塞自己,等子线程跑完再往下走。不wait直接去读共享变量?读到的很可能是算到一半的中间值,数字对不上,还找不到原因。
③num++不是原子操作。
这是线程不安全最经典的案例。一行代码,底层拆成好几条指令,中间随时可能被切走。数据竞争,就是从这儿钻进来的。
④ 优先用RAII守卫,别裸写lock/unlock。
QMutexLocker 这类守卫类,构造加锁、析构解锁,异常和提前return都拦不住它。裸写lock/unlock,一不小心漏了解锁,就是锁泄漏,其他线程永久阻塞,程序当场卡死。
⑤ 读写锁的优势,在"读多写少"。
QReadWriteLock的价值,是让多个读操作并发执行,提升吞吐量。写的时候,所有读全部阻塞,独占资源。读多写少的场景下,这套机制的收益非常明显。
⑥ 锁泄漏,后果很严重。
锁加完之后没释放,其他线程就会永久阻塞在lock()上,程序卡死。这不是小毛病,是能把整个服务拖垮的硬伤。
⑦ Qt锁和C++标准锁,能混,但别混。
两者底层封装的都是操作系统的mutex,技术上混用没问题。但工程实践里,接口和守卫类不统一,维护成本高,容易写出错。统一用一套,规矩清楚,排查也方便。
如果这篇文章对你有帮助,别忘了点赞、收藏、关注三连支持。你的每一次反馈,都是我继续硬核输出的最大动力。