【Qt】Qt多线程编程指南:提升应用性能与用户体验

前言

在现代软件开发中,多线程编程已成为一个不可或缺的技能,尤其是在需要处理复杂任务和提高应用程序性能的场合。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中实现多线程编程,编写出既高效又稳定的应用程序。

  • 21
    点赞
  • 18
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

Q_hd

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值