【QT】子類化QThread實現多執行緒

李春港發表於2020-11-09

這個是本文章例項的原始碼地址:https://gitee.com/CogenCG/QThreadExample.git

子類化QThread來實現多執行緒, QThread只有run函式是在新執行緒裡的,其他所有函式都在QThread生成的執行緒裡。正確啟動執行緒的方法是呼叫QThread::start()來啟動,如果直接呼叫run成員函式,這個時候並不會有新的執行緒產生( 原因: 可以檢視往期《QThread原始碼淺析》文章,瞭解下run函式是怎麼被呼叫的)。

一、步驟

  • 子類化 QThread;
  • 重寫run,將耗時的事件放到此函式執行;
  • 根據是否需要事件迴圈,若需要就在run函式中呼叫 QThread::exec() ,開啟執行緒的事件迴圈。事件迴圈的作用可以檢視往期《QThread原始碼淺析》文章中《QThread::run()原始碼》小節進行閱讀;
  • 為子類定義訊號和槽,由於槽函式並不會在新開的執行緒執行,所以需要在建構函式中呼叫 moveToThread(this)。 注意:雖然呼叫moveToThread(this)可以改變物件的執行緒依附性關係,但是QThread的大多數成員方法是執行緒的控制介面,QThread類的設計本意是將執行緒的控制介面供給舊執行緒(建立QThread物件的執行緒)使用。所以不要使用moveToThread()將該介面移動到新建立的執行緒中,呼叫moveToThread(this)被視為不好的實現。

接下來會通過 使用執行緒來實現計時器,並實時在UI上顯示 的例項來說明不使用事件迴圈和使用事件迴圈的情況。(此例項使用QTimer會更方便,此處為了說明QThread的使用,故使用執行緒來實現)

二、不使用事件迴圈例項

InheritQThread.hpp

class InheritQThread:public QThread
{
    Q_OBJECT
public:
    InheritQThread(QObject *parent = Q_NULLPTR):QThread(parent){
        
    }
    
    void StopThread(){
        QMutexLocker lock(&m_lock);
        m_flag = false;
    }
    
protected:
    //執行緒執行函式
    void run(){
        qDebug()<<"child thread = "<<QThread::currentThreadId();
        int i=0;
        m_flag = true;
        
        while(1)
        {
            ++i;
            emit ValueChanged(i); //傳送訊號不需要事件迴圈機制
            QThread::sleep(1);
            
            {
                QMutexLocker lock(&m_lock);
                if( !m_flag )
                    break;
            }
            
        }
    }
    
signals:
    void ValueChanged(int i);
    
public:
    bool m_flag;
    QMutex m_lock;
};

mainwindow.hpp

class MainWindow : public QMainWindow
{
    Q_OBJECT
public:
    explicit MainWindow(QWidget *parent = nullptr) :
        QMainWindow(parent),
        ui(new Ui::MainWindow){
        ui->setupUi(this);
        
        qDebug()<<"GUI thread = "<<QThread::currentThreadId();
        WorkerTh = new InheritQThread(this);
        connect(WorkerTh, &InheritQThread::ValueChanged, this, &MainWindow::setValue);
    }
    
    ~MainWindow(){
        delete ui;
    }
    
public slots:
    void setValue(int i){
        ui->lcdNumber->display(i);
    }
    
private slots:
    void on_startBt_clicked(){
        WorkerTh->start();
    }
    
    void on_stopBt_clicked(){
        WorkerTh->StopThread();
    }
    
    void on_checkBt_clicked(){
        if(WorkerTh->isRunning()){
            ui->label->setText("Running");
        }else{
            ui->label->setText("Finished");
        }
    }
    
private:
    Ui::MainWindow *ui;
    InheritQThread *WorkerTh;
};

在使用多執行緒的時候,如果出現共享資源使用,需要注意資源搶奪的問題,例如上述InheritQThread類中m_flag變數就是一個多執行緒同時使用的資源,上面例子使用 QMutexLocker+QMutex 的方式對臨界資源進行安全保護使用,其實際是使用了 RAII技術:(Resource Acquisition Is Initialization),也稱為“資源獲取就是初始化”,是C++語言的一種管理資源、避免洩漏的慣用法。C++標準保證任何情況下,已構造的物件最終會銷燬,即它的解構函式最終會被呼叫。簡單的說,RAII 的做法是使用一個物件,在其構造時獲取資源,在物件生命期控制對資源的訪問使之始終保持有效,最後在物件析構的時候釋放資源。具體 QMutexLocker+QMutex 互斥鎖的原理以及使用方法,在這裡就不展開說了,這個知識點網上有很多非常好的文章。

效果:

(1)在不點【start】按鍵的時候,點選【check thread state】按鈕檢查執行緒狀態,該執行緒是未開啟的。

(2)按下【start】後效果如下,並檢視終端訊息列印資訊:


只有呼叫了QThread::start()後,子執行緒才是真正的啟動,並且只有在run()函式才處於子執行緒內。

(3)我們再試一下點選【stop】按鈕,然後檢查執行緒的狀態:

點選【stop】按鈕使 m_flag = false, 此時run函式也就可以跳出死迴圈,並且停止了執行緒的運作,之後我們就不能再次使用該執行緒了,也許有的人說,我再一次start不就好了嗎?再一次start已經不是你剛才使用的執行緒了,這是start的是一個全新的執行緒。到此子類化 QThread ,不使用事件迴圈的執行緒使用就實現了,就這麼簡單。

三、使用事件迴圈例項

run函式中的 while 或者 for 迴圈執行完之後,如果還想讓執行緒保持運作,後期繼續使用,那應該怎麼做?
可以啟動子執行緒的事件迴圈,並且使用訊號槽的方式繼續使用子執行緒。注意:一定要使用訊號槽的方式,否則函式依舊是在建立QThread物件的執行緒執行。

  • 在run函式中新增QThread::exec()來啟動事件迴圈。(注意: 在沒退出事件迴圈時,QThread::exec()後面的語句都無法被執行,退出後程式會繼續執行其後面的語句);
  • 為QThread子類定義訊號和槽;
  • 在QThread子類建構函式中呼叫 moveToThread(this)(注意: 可以實現建構函式在子執行緒內執行,但此方法不推薦,更好的方法會在後期的文章進行介紹)。

接著上述的例項,在InheritQThread類建構函式中新增並且呼叫moveToThread(this);在run函式中新增exec();並定義槽函式:

/**************在InheritQThread建構函式新增moveToThread(this)**********/
InheritQThread(QObject *parent = Q_NULLPTR):QThread(parent){
        moveToThread(this); 
    }

/**************在InheritQThread::run函式新增exec()***************/
void run(){
    qDebug()<<"child thread = "<<QThread::currentThreadId();

    int i=0;
    m_flag = true;

    while(1)
    {
        ++i;

        emit ValueChanged(i);
        QThread::sleep(1);

        {
            QMutexLocker lock(&m_lock);
            if( !m_flag )
                break;
        }
    }
    
    exec(); //開啟事件迴圈
    }

/************在InheritQThread類中新增QdebugSlot()槽函式***************/
public slots:
    void QdebugSlot(){
        qDebug()<<"QdebugSlot function is in thread:"<<QThread::currentThreadId();
    }

在MainWindow類中新增QdebugSignal訊號;在建構函式中將QdebugSignal訊號與InheritQThread::QdebugSlot槽函式進行綁;新增一個傳送QdebugSignal訊號的按鈕:

/**********在MainWindow建構函式中繫結訊號槽******************/
explicit MainWindow(QWidget *parent = nullptr) :
    QMainWindow(parent),
    ui(new Ui::MainWindow){

    qDebug()<<"GUI thread = "<<QThread::currentThreadId();

    ui->setupUi(this);
    WorkerTh = new InheritQThread(this);
    connect(WorkerTh, &InheritQThread::ValueChanged, this, &MainWindow::setValue);
    connect(this, &MainWindow::QdebugSignal, WorkerTh, &InheritQThread::QdebugSlot); //繫結訊號槽
}

/********MainWindow類中新增訊號QdebugSignal槽以及按鈕事件槽函式**********/
signals:
    void QdebugSignal(); //新增QdebugSignal訊號
private slots:
    //按鈕的事件槽函式
    void on_SendQdebugSignalBt_clicked()
    {
        emit QdebugSignal();
    }

實現事件迴圈的程式已修改完成,來看下效果:

(1)在執行的時候為什麼會出現以下警告?

QObject::moveToThread: Cannot move objects with a parent

我們看到MainWindow類中是這樣定義InheritQThread類物件的:WorkerTh = new InheritQThread(this)。如果需要使用moveToThread()來改變物件的依附性,其建立時不能夠帶有父類。將語句改為:WorkerTh = new InheritQThread()即可。

(2)修改完成後,點選【start】啟動執行緒,然後點選【stop】按鈕跳出run函式中的while迴圈,最後點選【check thread state】按鈕來檢查執行緒的狀態,會是什麼樣的情況呢?

由上圖可以看到,執行緒依舊處於執行狀態,這是因為run函式中呼叫了exec(),此時執行緒正處於事件迴圈中。

(3)接下來再點選【Send QdebugSignal】按鈕來傳送QdebugSignal訊號。

由終端的列印資訊得知,InheritQThread::QdebugSlot槽函式是在子執行緒中執行的。

四、子類化QThread執行緒的訊號與槽

從上圖可知,事件迴圈是一個無止盡迴圈,事件迴圈結束之前,exec()函式後的語句無法得到執行。只有槽函式所線上程開啟了事件迴圈,它才能在對應訊號發射後被呼叫。無論事件迴圈是否開啟,訊號傳送後會直接進入槽函式所依附的執行緒的事件佇列,然而,只有開啟了事件迴圈,對應的槽函式才會線上程中得到呼叫。下面通過幾種情況來驗證下:

(1)程式碼和《三、使用事件迴圈》小節的程式碼一樣,然後進行如下的操作:點選【start】按鈕->再點選【Send QdebugSignal】按鈕,這個時候槽函式會不會被執行呢?


這種情況無論點多少次傳送QdebugSignal訊號,InheritQThread::QdebugSlot槽函式都不會執行。因為當前執行緒還處於while迴圈當中,如果需要實現槽函式在當前執行緒中執行,那麼當前執行緒就應該處於事件迴圈的狀態,也就是正在執行exec()函式。所以如果需要InheritQThread::QdebugSlot槽函式執行,就需要點選【stop】按鈕退出while迴圈,讓執行緒進入事件迴圈。

(2)在《三、使用事件迴圈》小節的程式碼基礎上,把InheritQThread::run函式刪除,然後進行如下的操作:點選【start】啟動執行緒->點選【stop】按鈕跳出run函式中的while迴圈進入事件迴圈->點選【Send QdebugSignal】按鈕來傳送QdebugSignal訊號,會有什麼結果呢?

結果會和上面第一種情況一樣,雖然訊號已經在子執行緒的事件佇列上,但是由於子執行緒沒有事件迴圈,所以槽函式永遠都不會被執行。

(3)在上面《三、使用事件迴圈》小節的程式碼基礎上,將InheritQThread建構函式中的 moveToThread(this) 去除掉。進行如下操作:點選【start】啟動執行緒->點選【stop】按鈕跳出run函式中的while迴圈進入事件迴圈->點選【Send QdebugSignal】按鈕來傳送QdebugSignal訊號,會有什麼結果呢?

由上圖可以看出InheritQThread::QdebugSlot槽函式居然是在GUI主執行緒中執行了。因為InheritQThread物件我們是在主執行緒中new出來的,如果不使用moveToThread(this)來改變物件的依附性關係,那麼InheritQThread物件就是屬於GUI主執行緒,根據connect訊號槽的執行規則,最終槽函式會在物件所依賴的執行緒中執行。訊號與槽繫結的connect函式的細節會在後期的《跨執行緒的訊號槽》文章進行單獨介紹。

五、如何正確退出執行緒並釋放資源

InheritQThread類的程式碼不變動,和上述的程式碼一樣:

#ifndef INHERITQTHREAD_H
#define INHERITQTHREAD_H
#include <QThread>
#include <QMutex>
#include <QMutexLocker>
#include <QDebug>

class InheritQThread:public QThread
{
    Q_OBJECT

public:
    InheritQThread(QObject *parent = Q_NULLPTR):QThread(parent){
        moveToThread(this);
    }

    void StopThread(){
        QMutexLocker lock(&m_lock);
        m_flag = false;
    }

protected:
    //執行緒執行函式
    void run(){
        qDebug()<<"child thread = "<<QThread::currentThreadId();

        int i=0;
        m_flag = true;

        while(1)
        {
            ++i;

            emit ValueChanged(i);
            QThread::sleep(1);

            {
                QMutexLocker lock(&m_lock);
                if( !m_flag )
                    break;
            }
        }

        exec();
    }

signals:
    void ValueChanged(int i);

public slots:
    void QdebugSlot(){
        qDebug()<<"QdebugSlot function is in thread:"<<QThread::currentThreadId();
    }

public:
    bool m_flag;
    QMutex m_lock;
};

#endif // INHERITQTHREAD_H

MainWindow類新增ExitBt、TerminateBt兩個按鈕,用於呼叫WorkerTh->exit(0)、WorkerTh->terminate()退出執行緒函式。由往期《QThread原始碼淺析》文章中《QThread::quit()、QThread::exit()、QThread::terminate()原始碼》小節得知呼叫quit和exit是一樣的,所以本處只新增了ExitBt按鈕:

#ifndef MAINWINDOW_H
#define MAINWINDOW_H

#include <QMainWindow>
#include "ui_mainwindow.h"
#include "InheritQThread.h"
#include <QThread>
#include <QDebug>

namespace Ui {
class MainWindow;
}

class MainWindow : public QMainWindow
{
    Q_OBJECT

public:
    explicit MainWindow(QWidget *parent = nullptr) :
        QMainWindow(parent),
        ui(new Ui::MainWindow){

        qDebug()<<"GUI thread = "<<QThread::currentThreadId();

        ui->setupUi(this);
        WorkerTh = new InheritQThread();
        connect(WorkerTh, &InheritQThread::ValueChanged, this, &MainWindow::setValue);

        connect(this, &MainWindow::QdebugSignal, WorkerTh, &InheritQThread::QdebugSlot);
    }

    ~MainWindow(){
        delete ui;
    }

signals:
    void QdebugSignal();

public slots:
    void setValue(int i){
        ui->lcdNumber->display(i);
    }

private slots:
    void on_startBt_clicked(){
        WorkerTh->start();
    }

    void on_stopBt_clicked(){
        WorkerTh->StopThread();
    }

    void on_checkBt_clicked(){
        if(WorkerTh->isRunning()){
            ui->label->setText("Running");
        }else{
            ui->label->setText("Finished");
        }
    }

    void on_SendQdebugSignalBt_clicked(){
        emit QdebugSignal();
    }

    void on_ExitBt_clicked(){
        WorkerTh->exit(0);
    }

    void on_TerminateBt_clicked(){
        WorkerTh->terminate();
    }

private:
    Ui::MainWindow *ui;
    InheritQThread *WorkerTh;
};

#endif // MAINWINDOW_H

執行上述的例程,點選【start】啟動執行緒按鈕,然後直接點選【exit(0)】或者【terminate()】,這樣會直接退出執行緒嗎?
點選【exit(0)】按鈕(猛點)

點選【terminate()】按鈕(就點一點)

由上述情況我們可以看到上面例程的執行緒啟動之後,無論怎麼點選【start】按鈕,執行緒都不會退出,點選【terminate()】按鈕的時候就會立刻退出當前執行緒。由往期《QThread原始碼淺析》文章中《QThread::quit()、QThread::exit()、QThread::terminate()原始碼》小節可以得知,若使用QThread::quit()、QThread::exit()來退出執行緒,該執行緒就必須要在事件迴圈的狀態(也就是正在執行exec()),執行緒才會退出。而QThread::terminate()不管執行緒處於哪種狀態都會強制退出執行緒,但這個函式存在非常多不安定因素,不推薦使用。我們下面來看看如何正確退出執行緒。

(1)如何正確退出執行緒?

  • 如果執行緒內沒有事件迴圈,那麼只需要用一個標誌變數來跳出run函式的while迴圈,這就可以正常退出執行緒了。
  • 如果執行緒內有事件迴圈,那麼就需要呼叫QThread::quit()或者QThread::exit()來結束事件迴圈。像剛剛舉的例程,不僅有while迴圈,迴圈後面又有exec(),那麼這種情況就需要先讓執行緒跳出while迴圈,然後再呼叫QThread::quit()或者QThread::exit()來結束事件迴圈。如下:

注意:儘量不要使用QThread::terminate()來結束執行緒,這個函式存在非常多不安定因素。

(2)如何正確釋放執行緒資源?

退出執行緒不代表執行緒的資源就釋放了,退出執行緒只是把執行緒停止了而已,那麼QThread類或者QThread派生類的資源應該如何釋放呢?直接 delete QThread類或者派生類的指標嗎?當然不能這樣做,千萬別手動delete執行緒指標,手動delete會發生不可預料的意外。理論上所有QObject都不應該手動delete,如果沒有多執行緒,手動delete可能不會發生問題,但是多執行緒情況下delete非常容易出問題,那是因為有可能你要刪除的這個物件在Qt的事件迴圈裡還排隊,但你卻已經在外面刪除了它,這樣程式會發生崩潰。 執行緒資源釋放分為兩種情況,一種是在建立QThread派生類時,新增了父物件,例如在MainWindow類中WorkerTh = new InheritQThread(this)讓主窗體作為InheritQThread物件的父類;另一種是不設定任何父類,例如在MainWindow類中WorkerTh = new InheritQThread()。

  • 1、建立QThread派生類,有設定父類的情況:

這種情況,QThread派生類的資源都讓父類接管了,當父物件被銷燬時,QThread派生類物件也會被父類delete掉,我們無需顯示delete銷燬資源。但是子執行緒還沒結束完,主執行緒就destroy掉了(WorkerTh的父類是主執行緒視窗,主執行緒視窗如果沒等子執行緒結束就destroy的話,會順手把WorkerTh也delete這時就會奔潰了)。 注意:這種情況不能使用moveToThread(this)改變物件的依附性。 因此我們應該把上面MainWindow類的建構函式改為如下:

~MainWindow(){
    WorkerTh->StopThread();//先讓執行緒退出while迴圈
    WorkerTh->exit();//退出執行緒事件迴圈
    WorkerTh->wait();//掛起當前執行緒,等待WorkerTh子執行緒結束
    delete ui;
}
  • 2、建立QThread派生類,沒有設定父類的情況:

也就是沒有任何父類接管資源了,又不能直接delete QThread派生類物件的指標,但是QObject類中有 void QObject::deleteLater () [slot] 這個槽,這個槽非常有用,後面會經常用到它用於安全的執行緒資源銷燬。我們通過檢視往期《QThread原始碼淺析》文章中《QThreadPrivate::start()原始碼》小節可知執行緒結束之後會發出 QThread::finished() 的訊號,我們將這個訊號和 deleteLater 槽繫結,執行緒結束後呼叫deleteLater來銷燬分配的記憶體。
在MainWindow類建構函式中,新增以下程式碼:

connect(WorkerTh, &QThread::finished, WorkerTh, &QObject::deleteLater) 

~MainWindow()解構函式可以把 wait()函式去掉了,因為該執行緒的資源已經不是讓主視窗來接管了。當我們啟動執行緒之後,然後退出主視窗或者直接點選【stop】+【exit()】按鈕的時候,會出現以下的警告:

QThread::wait: Thread tried to wait on itself
QThread: Destroyed while thread is still running

為了讓子執行緒能夠響應訊號並在子執行緒執行槽函式,我們在InheritQThread類建構函式中新增了 moveToThread(this) ,此方法是官方極其不推薦使用的方法。那麼現在我們就遇到了由於這個方法引發的問題,我們把moveToThread(this)刪除,程式就可以正常結束和釋放資源了。那如果要讓子執行緒能夠響應訊號並在子執行緒執行槽函式,這應該怎麼做?在下一期會介紹一個官方推薦的《子類化QObject+moveToThread》的方法。

六、小結

  • QThread只有run函式是在新執行緒裡;
  • 如果必須需要實現線上程內執行槽的情景,那就需要在QThread的派生類建構函式中呼叫moveToThread(this),並且在run函式內執行QThread::exec()開啟事件迴圈;(極其不推薦使用moveToThread(this),下一期會介紹一種安全可靠的方法)
  • 若需要使用事件迴圈,需要在run函式中呼叫QThread::exec();
  • 儘量不要使用terminate()來結束執行緒,可以使用bool標誌位退出或者線上程處於事件迴圈時呼叫QThread::quit、QThread::exit來退出執行緒;
  • 善用QObject::deleteLater來進行記憶體管理;
  • 在QThread執行start函式之後,run函式還未執行完畢,再次start,不會發生任何結果;
  • 子類化QThread多執行緒的方法適用於後臺執行長時間的耗時操作、單任務執行的、無需線上程內執行槽的情景。

這個是本文章例項的原始碼地址:https://gitee.com/CogenCG/QThreadExample.git

相關文章