Qt学习笔记:多线程的使用


前言

程序中调用耗时的操作(如大批量I/O、实时通信、大计算量算法等)会造成用户界面的卡顿,通常将这一类操作放在子线程中来解决这一问题。


1. 何时使用线程

在进行桌面应用程序开发的时候,如果应用程序在某些情况下需要处理比较复杂的逻辑, 如果只有一个线程去处理,就会导致窗口卡顿,无法处理用户的相关操作,这种情况下就需要使用多线程。在 Qt 中使用多线程应遵循以下原则:

  • 遵循前后台分离的设计原则,前台主线程负责窗口事件处理或者窗口控件数据的更新,子线程负责后台的业务逻辑处理,不能对窗口对象做任何操作。
  • 线程之间进行数据的传递,使用Qt中的信号槽机制。

2. QThread类实现多线程

2.1 多线程的实现方法

Qt 使用QThread类实现多线程,具体实现方法有两种:一种是创建继承于QThread的线程类,并重写run()方法(不推荐);另一种则是创建继承于QObject的工作类(Worker Class),在主线程中通过QObject::moveToThread()方法将工作类移动到子线程中(推荐)。

1) subclass QThread

注意:这种使用方法并不推荐,Bradley T. Hughes2010 专门写了篇博客 You’re doing it wrong…来讨论QThread的正确使用方法。

/*------------------------------WorkerThread-----------------------------------*/
class WorkerThread : public QThread
{
    Q_OBJECT
    void run() override {
        qDebug() << "Thread in run is" << QThread::currentThread();
        connect(ptcpSocket, &QTcpSocket::readyRead, this, [=]()
    	{
        	qDebug() << "Thread in slot is" << QThread::currentThread();
            QString result;
        	/* ... here is the expensive or blocking operation ... */
        	emit resultReady(result);
    	});
        exec();
    }
    
signals:
    void resultReady(const QString &s);
};

/*------------------------------MainWindow-----------------------------------*/
void MainWindow::startWorkInAThread()
{
    WorkerThread *workerThread = new WorkerThread(this);
    connect(workerThread, &WorkerThread::resultReady, this, &MainWindow::handleResults);
    connect(workerThread, &WorkerThread::finished, workerThread, &QObject::deleteLater);
    workerThread->start();
}

通过qDebug() << QThread::currentThread();可以打印当前代码所运行的线程。

在本例中,原本是想在子线程中实现tcp通信的数据接收功能,但实际上打印槽函数运行的线程却是主线程,原因在于Qt的信号槽机制:

QMetaObject::Connection QObject::connect(const QObject *sender, const char *signal, const QObject *receiver, const char *method, Qt::ConnectionType type = Qt::AutoConnection)

connect函数中,最后一个形参为连接类型Qt::ConnectionType,缺省类型为Qt::AutoConnection。信号发送时,确定连接类型:当信号发送者和接收者在同一线程内,使用Qt::DirectConnection;当信号发送者和接收者不在同一线程,使用Qt::QueuedConnection

Qt::DirectConnection:

信号发送时槽函数立刻被执行,此时槽函数处于发送信号的一线程。

Qt::QueuedConnection:

当控制权回到接收者所在的事件循环时,槽函数才被执行,此时槽函数在接收者所在线程。

因此,在例子中WorkerThread中的connect使用Qt::QueuedConnection作为连接类型。同时由于MainWindow中定义的workerThread对象处于主线程,因此槽函数也将在主线程中运行。如果你看过Qt自带的例子,你会发现 QThread 中 slot 和 run函数共同操作的对象,都会用QMutex锁住。为什么?因为 slot 和 run 处于不同线程,需要线程间的同步!

2) worker-object

Qt 4.4 版本之后完善了线程的亲和性以及信号槽机制,我们有了更为安全的使用线程的方式,即 QObject::moveToThread() 。使用信号和槽时根本不用考虑多线程的存在。也不用使用QMutex来进行同步,Qt的事件循环会自己自动处理。

/*---------------------------Worker----------------------------*/
class Worker : public QObject
{
    Q_OBJECT
public:
    explicit Worker(){
        pTimer = new QTimer;
        connect(pTimer,&QTimer::timeout,this,&Worker::onTimeout);
    }

public slots:
    void doWork(const QString &parameter) {
        QString result;
        /* ... here is the expensive or blocking operation ... */
        pTimer->start(interval);
        emit resultReady(result);
    }
    
private slots:
    void onTimout(){
        /* do something */
        qDebug() << "Thread in slot is:" << QThread::currrentThread();
    }

signals:
    void resultReady(const QString &result);
    
private:
    QTimer *pTimer;
};


/*---------------------------Controller----------------------------*/
class Controller : public QObject
{
    Q_OBJECT
    QThread workerThread;
public:
    Controller() {
        // 注意千万不要给创建的Worker对象指定父对象
        Worker *worker = new Worker;
        worker->moveToThread(&workerThread);
        connect(&workerThread, &QThread::finished, worker, &QObject::deleteLater);
        connect(this, &Controller::operate, worker, &Worker::doWork);
        connect(worker, &Worker::resultReady, this, &Controller::handleResults);
        workerThread.start();
    }
    ~Controller() {
        workerThread.quit();
        workerThread.wait();
    }
public slots:
    void handleResults(const QString &);
signals:
    void operate(const QString &);
};

相较于subclass QThread,使用worker-object方法最突出的优势在于:

  • 可以在子线程内自由地使用信号槽。在示例代码中,Worker类内部定义了一个定时器,响应定时器到时的槽函数onTimout()也是运行在子线程中的。
  • 可以在子线程内部定义多个槽函数,以实现不同的业务逻辑,而subclass QThread只能将所有任务塞进run()函数处理。
  • 可以将多个工作对象移动到一个子线程中,但需要注意的是,移动到一个线程中的工作对象是线性处理的,多个任务不能同步进行。

使用worker-object需要注意的是:

  • QObject::moveToThread()的作用是将槽函数放在指定的线程中调用。仅有槽函数在指定线程中调用,包括构造函数都仍然在主线程中调用!!!
  • 初始化worker对象时不要给他指定父对象。如果给work指定了父对象,就无法使用moveToThread()(提示: QObject::moveToThread: Cannot move objects with a parent)。

2.2 线程休眠

接口功能
void sleep(unsigned long secs)休眠(s)
void msleep(unsigned long msecs)休眠(ms)
void usleep(unsigned long usecs)休眠(us)

2.3 正确结束线程

删除正在运行的QThread将导致程序奔溃,需要正确地结束线程。

退出线程:quit()/exit() + wait()

// 线程的退出方式
workerThread->quit();
workerThread->wait();

如果QThread对象在堆内存区创建:

1.有父对象:可通过finished信号,连接deleteLater来让线程自杀。

WorkerThread *workerThread = new WorkerThread(this);
connect(workerThread, &WorkerThread::finished, workerThread, &QObject::deleteLater);

2.没有父对象:在主界面的析构函数或者通过destroyed信号,显式地释放堆内存。

// 析构函数
~MainWindow() 
{
    workerThread->quit();
	workerThread->wait();
	workerThread->deleteLater();
}

// destroyed信号
connect(this, &MainWindow::destroyed, this, [=]()
{
	workerThread->quit();
	workerThread->wait();
	workerThread->deleteLater();
});

3. 线程同步

3.1 互斥量

当出现多个线程需要操作同一个资源时,互斥量可用于保护一个资源一次仅被一个线程使用,Qt 中使用QMutexQMutexLocker实现。

QMutex mutex;
int number = 6;

void thread1()
{
    // 锁定互斥量
    mutex.lock();
    number *= 5;
    number /= 4;
    // 解锁互斥量
    mutex.unlock();
}

void thread2()
{
    mutex.lock();
    number *= 3;
    number /= 2;
    mutex.unlock();
}

QMutexLocker可以简化互斥量的处理,仅需在需要互斥量保护的函数中声明一个对象,函数结束时会自动对互斥量解锁。

void func()
{
    QMutexLocker locker(&mutex);
    /* do something */
}

3.2 信号量

信号量是互斥量的一般化,互斥量只能保护一个资源,而信号量可以用来保护一定数量的相同的资源,Qt 中使用QSemaphore类实现,常用的接口为:

接口功能
void acquire(int n = 1)获取n个资源(默认值1),当没有足够资源时调用者被阻塞,可用资源数量-n
void release(int n = 1)释放n个资源(默认值1),可用资源数量+n
int available() const返回当前可用资源的数量

信号量的典型用例是控制生产者/消费者之间共享的环形缓冲区,生产者/消费者对同步的需求为:

(1) 如果生产者过快地产生数据,就会覆盖消费者还没读取的数据。

(2) 如果消费者过快地读取数据,就会读取到生产者之前产生的过期数据。

可将生产者和消费者分为2个独立的线程,用2种信号量分别控制缓冲区中可写和可读的部分,可省略传统环形缓冲区中的读/写指针,简单有效。

全局变量存放在 global.h 和 global.cpp 中,使用时只需#include "global.h"即可:

/*---------------------------global.h----------------------------*/
extern const quint32 BUFFERSIZE;
extern const quint32 DATASIZE;
extern char g_szBuffer[BUFFERSIZE];
extern QSemaphore freeBytes;
extern QSemaphore usedBytes;

/*---------------------------global.cpp----------------------------*/
#include "global.h"

const quint32 BUFFERSIZE = 20;
const quint32 DATASIZE = 40;
char g_szBuffer[BUFFERSIZE]{'\0'}; //缓冲区数组
QSemaphore freeBytes(BUFFERSIZE); //缓冲区可写资源, 初始化数量为BUFFERSIZE
QSemaphore usedBytes; //缓冲区可读资源, 初始化数量为0

生产者线程(这里采用官方推荐的worker-object方法实现):

class CProducer : public QObject
{
    Q_OBJECT
public:
    explicit CProducer(QObject *parent = nullptr);

public slots:
    void produce();
};

void CProducer::produce()
{
    while(num < DATASIZE)
    {
        static quint32 num = 0; //生产资源总数
        quint16 len = QRandomGenerator::global()->bounded(BUFFERSIZE) + 1; //生产者一次生产的资源是随机的, 范围[1, BUFFERSIZE]
        freeBytes.acquire(len);
        for (auto i = num; i < num + len; ++i)
        {
            g_szBuffer[i % BUFFERSIZE] = QRandomGenerator::global()->bounded(100); //[i % BUFFERSIZE]是实现环形缓冲区的精髓
        }
        usedBytes.release(len);
        num += len;
    }
}

消费者线程:

class CConsumer : public QObject
{
    Q_OBJECT
public:
    explicit CConsumer(QObject *parent = nullptr);

public slots:
    void consume();

private:
    bool m_stopflag = false;
    const quint16 CONSUME_DATASIZE = 20; //消费者一次消费的资源数量最大值
    std::string charToHexString(const char& ch);
};

void CConsumer::consume()
{
    while(num < DATASIZE)
    {
        static quint32 num = 0;
        quint16 len = 0;
        if((usedBytes.available() - CONSUME_DATASIZE) >= 0) //确保消费的资源数量不大于缓冲区内可读的资源数量
        {
            len = CONSUME_DATASIZE;
        }
        else
        {
            len = (usedBytes.available() == 0)? 1 : usedBytes.available(); //保证acquire的数量不为0
        }
        usedBytes.acquire(len);
        for (auto i = num; i < num + len; ++i)
        {
            std::cout << charToHexString(g_szBuffer[i % BUFFERSIZE]) << std::endl;
        }
        freeBytes.release(len);
        num += len;
    }
}

main.cpp:

int main(int argc, char *argv[])
{
    QCoreApplication a(argc, argv);

    CProducer producer;
    QThread producerThread;
    producer.moveToThread(&producerThread);
    QObject::connect(&producerThread, &QThread::started, &producer, &CProducer::produce);

    CConsumer consumer;
    QThread consumerThread;
    consumer.moveToThread(&consumerThread);
    QObject::connect(&consumerThread, &QThread::started, &consumer, &CConsumer::consume);

    producerThread.start();
    consumerThread.start();
    producerThread.wait();
    consumerThread.wait();
    producerThread.quit();
    consumerThread.quit();
    return a.exec();
}

3.3 条件变量

条件变量用于阻塞和唤醒线程,Qt中使用QWaitCondition实现,常用的接口为:

接口功能
bool wait(QMutex *lockedMutex, QDeadlineTimer deadline = QDeadlineTimer(QDeadlineTimer::Forever))解锁互斥量lockedMutex并阻塞当前线程
void wakeOne()唤醒一个随机选取的被该条件变量阻塞的线程
void wakeAll()唤醒所有被该条件变量阻塞的线程

当调用条件变量的wait()方法时,线程进入阻塞状态,直到该条件变量调用wakeOne()或者wakeAll(),被阻塞的线程才被唤醒。

使用条件变量也可以实现生产者/消费者模式:

全局变量:

/*---------------------------global.h----------------------------*/
extern const quint16 BUFFERSIZE;
extern const quint32 DATASIZE;
extern char g_szBuffer[BUFFERSIZE];
extern QWaitCondition bufferNotEmpty;
extern QWaitCondition bufferNotFull;
extern QMutex g_mutex;
extern quint16 g_usedBytesNum;

/*---------------------------global.cpp----------------------------*/
#include "global.h"

const quint16 BUFFERSIZE = 20;
const quint32 DATASIZE = 40;
char g_szBuffer[BUFFERSIZE]{'\0'}; //缓冲区数组
QWaitCondition bufferNotEmpty;
QWaitCondition bufferNotFull;
QMutex g_mutex; //使用互斥量保证原子性
quint16 g_usedBytesNum = 0; //可读资源数量

生产者线程:

class CProducer : public QObject
{
    Q_OBJECT
public:
    explicit CProducer(QObject *parent = nullptr);

public slots:
    void produce();
};

void CProducer::produce()
{
    while(num < DATASIZE)
    {
        static quint32 num = 0;
        g_mutex.lock();
        if (g_usedBytesNum == BUFFERSIZE) //缓冲区被写满时触发bufferNotFull.wait()
            bufferNotFull.wait(&g_mutex);
        g_mutex.unlock();

        quint16 len = QRandomGenerator::global()->bounded(10);
        for (auto i = num; i < num + len; ++i)
        {
            g_szBuffer[i % BUFFERSIZE] = QRandomGenerator::global()->bounded(100);
        }
        num += len;

        g_mutex.lock();
        g_usedBytesNum += len;
        bufferNotEmpty.wakeAll(); //唤醒消费者线程
        g_mutex.unlock();
    }
}

消费者线程:

class CConsumer : public QObject
{
    Q_OBJECT
public:
    explicit CConsumer(QObject *parent = nullptr);

public slots:
    void consume();

private:
    bool m_stopflag = false;
    const quint16 CONSUME_DATASIZE = 20; //消费者一次消费的资源数量最大值

    std::string charToHexString(const char& ch);
};

void CConsumer::consume()
{
    while(num < DATASIZE)
    {
        g_mutex.lock();
        if (g_usedBytesNum == 0) //缓冲区读取完毕时触发bufferNotEmpty.wait()
            bufferNotEmpty.wait(&g_mutex);
        g_mutex.unlock();

        static quint32 num = 0;
        quint16 len = 0;
        if((g_usedBytesNum - CONSUME_DATASIZE) >= 0)
        {
            len = CONSUME_DATASIZE;
        }
        else
        {
            len = g_usedBytesNum;
        }

        for (auto i = num; i < num + len; ++i)
        {
            std::cout << charToHexString(g_szBuffer[i % BUFFERSIZE]) << std::endl;
        }
        num += len;

        g_mutex.lock();
        g_usedBytesNum -= len;
        bufferNotFull.wakeAll(); //唤醒生产者线程
        g_mutex.unlock();
    }
}

4. 线程池

通常情况下,一个并发的任务就会创建一个新的线程,但如果并发的线程数量很多,且线程执行时间不同步,频繁创建线程就会大大降低系统的效率,此时可以使用线程池来替我们管理线程。使用线程池有以下优点:

  • 线程池可以根据系统的需求和硬件环境灵活的控制线程并发的数量,且可以对所有线程进行统一的管理和控制。
  • 线程和任务分离,可以提升线程的重用性。
  • 提升系统响应速度,假如创建线程用的时间为T1,执行任务用的时间为T2,销毁线程用的时间为T3,那么使用线程池就免去了T1和T3的时间。

Qt中使用线程池比较方便,主要用到QRunnableQThreadPool类。其中QRunnable是添加到线程池的任务类,使用时需要创建子类继承 QRunnable ,然后重写run()方法,在这个函数内编写要执行的任务。

class CTask : public QObject, public QRunnable
{
    Q_OBJECT
public:
    explicit CProducer(QObject *parent = nullptr): QObject{parent}, QRunnable()
    {
    	//任务执行完毕,该对象自动销毁
    	setAutoDelete(true);
    }

    void run() override{
        /* do something */
    }
};

一般情况下,在主线程中不需要创建线程池对象,直接使用QThreadPool::globalInstance()获得线程池全局对象即可。通过调用全局对象的start()方法就可以将一个任务添加到线程池中,这样任务就可以被线程池中的某个工作的线程处理掉了。

MainWindow::MainWindow(QWidget *parent) :
    QMainWindow(parent),
    ui(new Ui::MainWindow)
{
    ui->setupUi(this);

    // 线程池初始化,设置最大线程数
    QThreadPool::globalInstance()->setMaxThreadCount(4);
    // 添加任务
    CTask* pTask = new CTask;
    QThreadPool::globalInstance()->start(pTask);    
}

参考资料

QThread Class

Qt connect

QMutex Class

QSemaphore Class

QWaitCondition Class

QT信号和槽在哪个线程执行问题

Qt - 一文理解QThread多线程(万字剖析整理)

Qt 中多线程的使用

Qt 中线程池的使用

  • 7
    点赞
  • 49
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值