文章目录
- 前言
- 1. Qt 多线程概述
- 2. QThread 常用 API
- 3. 使用线程
- 4. 多线的使用场景
- 5. 线程安全问题
- 5.1. 加锁
- 5.2. QReadWriteLocker、QReadLocker、QWriteLocker
- 6. 条件变量 与 信号量
- 6.1. 条件变量
- 6.2 信号量
- 总结
前言
在现代软件开发中,多线程编程已成为一个不可或缺的技能,尤其是在需要处理复杂任务和提高应用程序性能的场合。Qt,作为一个跨平台的应用程序框架,提供了强大的多线程支持,使得开发者能够充分利用多核处理器的优势,开发出响应迅速且高效的应用程序。本文将深入探讨Qt多线程的基本概念、API使用、线程安全问题以及同步机制,旨在帮助开发者更好地理解和运用Qt的多线程功能。
1. Qt 多线程概述
Qt 多线程 和 Linux 中线程,本质是一个东西。
Linux 中的各种和线程相关的 原理 和 注意事项,都是在Qt中适用的。
Qt 中的多线程 API
Linux 中的多线程 API,Linux 系统提供的 pthread 库,Qt 中针对系统提供的线程 API 重新封装了。
C++ 11 中,也引入了线程 std::thread
Linux 原生多线程 API,设计的非常差,使用起来非常不方便(也是 C 语言本身的局限性引起的)实际开发中,很少使用原生 api
std::thread
要比 Linux 的 API 要更好一些
Qt 中的多线程 API,还要好一点,其实参考了 Java 中的线程库 API 的设计方式。
QThread
要想创建线程,就要创建出这样的实例,创建线程的时候,需要重点指定线程的入口函数。创建一个 QThread
的子类,重写其中 run
函数,起到指定函数入口的方式(多态)
(C++ 中这种做法,不算特别常见,相比之下 std::thread
直接指定回调的方式更常见一些,有些 C++ 的大佬,认为多态机制,带来运行时的额外开销(运行时,查询虚函数表,找到对应的函数再执行))
有些场景确实对于性能追求到极致(游戏引擎,AI,做高性能服务器…)
Qt 做客户端开发,客户端性能只要不太拉跨就行!
性能从来不是Qt优先追求的
2. QThread 常用 API
start()
: 这个操作就是真正调用系统 API 创建线程,新的线程创建出来之后自然就会自动执行 run
函数。
可以使用 wait
, 让一个线程等待另一个线程执行结束
3. 使用线程
实例:
之前基于定时器,写过倒计时这样的程序。
也可以通过线程,来完成类似的功能。定时器内部本质上也是可以基于多线程来实现的。(Qt 的定时器是否基于多线程,不太清楚)
创建另一个线程,新线程中,进行计时(搞一个循环,每循环一次,sleep 1s,sleep完成,就可以更新界面了)
由于存在线程安全问题,多个线程时对于界面的状态进行修改,此时就会导致界面就出错了。Qt选择了一刀切!针对界面控件状态进行任何修改,务必在主线程中执行。
// widget.h
#ifndef WIDGET_H
#define WIDGET_H
#include <QWidget>
#include "thread.h"
QT_BEGIN_NAMESPACE
namespace Ui { class Widget; }
QT_END_NAMESPACE
class Widget : public QWidget
{
Q_OBJECT
public:
Widget(QWidget *parent = nullptr);
~Widget();
Thread thread;
void handle();
private:
Ui::Widget *ui;
};
#endif // WIDGET_H
// widget.cpp
#include "widget.h"
#include "ui_widget.h"
Widget::Widget(QWidget *parent)
: QWidget(parent)
, ui(new Ui::Widget)
{
ui->setupUi(this);
// 连接信号槽,通过槽函数跟新界面
connect(&thread, &Thread::notify, this, &Widget::handle);
// 要启动一下线程
thread.start();
}
Widget::~Widget()
{
delete ui;
}
void Widget::handle()
{
// 此处修改界面内容
int value = ui->lcdNumber->intValue();
value--;
ui->lcdNumber->display(value);
}
// thread.h
#ifndef THREAD_H
#define THREAD_H
#include <QWidget>
#include <QThread>
class Thread : public QThread
{
Q_OBJECT
public:
Thread();
// 要用的目的是重写父类的方法 run 方法
void run();
signals:
void notify(); // 只用声明不用定义
};
#endif // THREAD_H
// thread.cpp
#include "thread.h"
Thread::Thread()
{
}
void Thread::run()
{
// 在这个 run 中。能否直接去进行修改界面内容呢?
// 不可以!!!
// 虽然不可以修改界面,但是可以针对时间来进行计时
// 当每到一秒钟的时候,通过信号槽,来通知主线程,负责更新界面内容
for (int i = 0; i < 10; ++i) {
// sleep 本身是 QThead 的成员函数, 就可以直接调用
sleep(1);
// 发送一个信号,通知主线程
emit notify();
}
}
4. 多线的使用场景
之前学习多线程,主要还是站在服务器开发的角度来看待的。
当时谈到多线程,最主要的目的,是为了充分利用多核 CPU 的计算资源。双路 CPU(一个主板上有两个CPU)。
客户端,多线程仍然非常有意义,侧重点就不同了,对于普通用户来说,“使用体验”是一个非常重要的话题。
如果“非常快”的代价是“系统很卡”用户大概率是不会买账的,虽然普通用户的家用 PC 上也是多核CPU,客户端上的程序很少会使用多线程把 CPU 计算资源吃完。
相比之下,客户端的多线程,主要是用于,通过多线程的方式,执行一些耗时的操作,避免主线程被卡死,避免对用户造成一些不好的体验。
比方说,客户端经常会和服务器进行网络通信,比方说客户端要上传/下载一个很大的文件,传输需要好久(20分钟)(像这样就是算是密集的IO操作,比如代码中持续不断的进行 QFile.write)这种密集 IO 就会使程序被系统阻塞,挂起;一旦进程都被挂起了,此时意味着,用户进行各种操作,程序都无响应。(比如,启动吃鸡,启动过程中就需要从文件/网络 加载大量的资源,此时如果你狂点鼠标窗口,很可能这个窗口就僵住了)“WIndows 提示你这个窗口不能响应,是否要强制结束!”
因此,相比之下,更好的做法,使用单独的线程,来处理这种密集 IO 操作,要挂起也是挂起这个新的线程。主线程负责用户的各种操作,此时主线程仍然可以继续工作,继续响应用户的各种操作。
5. 线程安全问题
多线程程序太复杂了
5.1. 加锁
把多个线程访问的公共资源,通过锁保护起来。把并发执行变成串行执行。
Linux: mutex
互斥量。
C++11: 引入 std::mutex
Qt 同样也提供了对应的锁,来针对系统提供的锁进行封装。
QMutex
: lock 加锁, unlock 解锁。
void Thread::run()
{
for (int i = 0; i < 50000; ++i) {
++num;
}
}
#include "mainwindow.h"
#include "ui_mainwindow.h"
#include "thread.h"
#include <QDebug>
MainWindow::MainWindow(QWidget *parent)
: QMainWindow(parent)
, ui(new Ui::MainWindow)
{
ui->setupUi(this);
// 创建两个线程对象
Thread t1;
Thread t2;
t1.start();
t2.start();
// 加上线程的等待,让主线程等待这两线程执行结束
t1.wait();
t2.wait();
// 打印结果
qDebug() << Thread::num;
}
MainWindow::~MainWindow()
{
delete ui;
}
由于三个线程之间是并发执行的关系,当 t1 和 t2 运行起来之后,主线程仍然会继续往后执行,执行到打印的时候,大概率 t1、t2 还没执行呢,所以要加上wait
,进行阻塞等待!
最后打印出来的结果并不是我们预期中的100,000 !说明是存在bug,说明是存在线程安全问题!
// 创建锁对象
static QMutex mutex;
多个线程加锁的对象,得是同一个对象!不同对象,此时不会产生锁的互斥,也就无法把 并发执行 -> 串行执行,也就无法解决上述问题。
#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();
}
}
++num;
是一个两个线程访问的公共变量,之前如果并发执行,就可能第一个线程修改了一半,第二个线程也进行了修改,就容易出现问题。(++操作对应 三个cpu指令,在操作系统中详细介绍)
加了锁之后,第一个线程顺利拿到锁,继续执行++
,在第一个线程没有执行完的时候,第二个线程也尝试枷锁,就会阻塞等待。一直等待到第一个线程加锁,第二个线程才可能从阻塞中被唤醒。
for (int i = 0; i < 50000; ++i) {
mutex.lock();
++num;
mutex.unlock();
}
像这里的锁,很容易忘记unlock
,实际开发中, 加锁之后,涉及到的逻辑可能很复杂,下面很容易就忘记释放锁。
比如下面,也算是没释放锁:
mutex.lock();
if (...) {
return;
}
mutex.unlock();
或者,抛出异常,释放动态内存,也会存在类似的问题。
C++ 引入 智能指针,就是为了解决上述的问题。
C++11 引入了 std::lock_guard
, 相当于是 std::lock_guard
, 相当于是 std::mutex
智能指针, 借助 RAII 机制。
{
std::lock_guard guard(mutex);
// 执行各种逻辑...
} // 大括号执行完毕,guard 变量的声明周期结束,在析构的时候,执行unlock了。
上述方案,Qt 也参考过来了: QMutexLocker
#include "thread.h"
#include <QMutexLocker>
int Thread::num = 0;
QMutex Thread::mutex;
Thread::Thread()
{
}
void Thread::run()
{
for (int i = 0; i < 50000; ++i) {
QMutexLocker locker(&mutex);
// mutex.lock();
++num;
// mutex.unlock();
}
}
Qt 的锁 和 C++标准库中的锁,本质上都是封装的系统提供的锁,编写多线程代码的时候,可以使用 Qt 的锁,也可以使用 C++ 的锁。
C++ 的锁能锁Qt 的线程吗? 是可以的!(虽然混着用也行,但一般不建议)
5.2. QReadWriteLocker、QReadLocker、QWriteLocker
特点:
QReadWriteLock
是读写锁类,用于控制读和写的并发访问。
QReadLocker
用于读操作上锁,允许多个线程同时读取共享资源。
QWriteLocker
用于写操作上锁,只允许一个线程写入共享资源。
用途:在某些情况下,多个线程可以同时读取共享数据,但只有一个线程能够进行写操作。读写锁提供了更高效的并发访问方式。
QReadWriteLock rwLock;
//在读操作中使⽤读锁
{
QReadLocker locker(&rwLock); //在作⽤域内⾃动上读锁
//读取共享资源
//...
} //在作⽤域结束时⾃动解读锁
//在写操作中使⽤写锁
{
QWriteLocker locker(&rwLock); //在作⽤域内⾃动上写锁
//修改共享资源
//...
} //在作⽤域结束时⾃动解写锁
6. 条件变量 与 信号量
Qt 中的条件变量 与 信号量 和 Linux 中的条件变量、信号量一致。
6.1. 条件变量
多个线程,之间调度是无序的,为了能够一定程度干预线程之间的顺序引入条件变量。
在 Qt 中,专门提供了 QWaitCondition
类 来解决像上述这样的问题。
wait
:中就会先释放锁 + 等待
要想释放锁,前提就是先获取到锁。
QMutex mutex;
QWaitCondition condition;
//在等待线程中
mutex.lock();
//检查条件是否满⾜,若不满⾜则等待
while (!conditionFullfilled()) //
{
condition.wait(&mutex); //等待条件满⾜并释放锁
}
//条件满⾜后继续执⾏
//...
mutex.unlock();
//在改变条件的线程中
mutex.lock();
//改变条件
changeCondition();
condition.wakeAll(); //唤醒等待的线程
mutex.unlock();
判定线程继续执行的条件是否成立,不成立就进行wait
等待。
这里要使用 while
判定而不是 if
,因为唤醒之后还需要确认一下当前条件是不是真的成立了。wait
可能被提前唤醒(可能被信号打断了)
6.2 信号量
有时在多线程编程中,需要确保多个线程可以相应的访问⼀个数量有限的相同资源。例如,运行程序的设备可能是非常有限的内存,因此我们更希望需要大量内存的线程将这⼀事实考虑在内,并根据可用的内存数量进行相关操作,多线程编程中类似问题通常用信号量来处理。信号量类似于增强的互斥锁,不仅能完成上锁和解锁操作,而且可以跟踪可用资源的数量。
特点:QSemaphore 是 Qt 框架提供的计数信号量类,用于控制同时访问共享资源的线程数量。
用途:限制并发线程数量,用于解决⼀些资源有限的问题。
QSemaphore semaphore(2); //同时允许两个线程访问共享资源
//在需要访问共享资源的线程中
semaphore.acquire(); //尝试获取信号量,若已满则阻塞
//访问共享资源
//...
semaphore.release(); //释放信号量
//在另⼀个线程中进⾏类似操作
总结
本文详细介绍了Qt多线程的各个方面,从基础概念到实际应用,再到线程安全和同步机制的讨论。首先,我们概述了Qt多线程与Linux线程的关系,并比较了Qt、C++11和Linux原生API的优缺点。接着,我们深入探讨了QThread的常用API和如何使用线程来执行耗时操作,同时强调了Qt中界面更新必须在主线程中进行的原则。
在多线程的使用场景中,我们讨论了多线程在客户端开发中的重要性,尤其是在提升用户体验方面的作用。随后,文章重点讨论了线程安全问题,包括加锁机制、读写锁以及条件变量和信号量的使用,这些都是确保多线程程序正确运行的关键技术。
最后,通过实际代码示例,我们展示了如何在Qt中创建和管理线程,以及如何使用锁和其他同步机制来处理线程间的通信和数据共享。通过本文的学习,开发者应该能够更加自信地在Qt中实现多线程编程,编写出既高效又稳定的应用程序。