【QT】子類化QObject+moveToThread實現多執行緒

李春港發表於2020-11-09

往期連結:

從往期《QThread原始碼淺析》可知,在Qt4.4之前,run 是純虛擬函式,必須子類化QThread來實現run函式。而從Qt4.4開始,QThread不再支援抽象類,run 預設呼叫 QThread::exec() ,不需要子類化QThread,只需要子類化一個QObject,通過QObject::moveToThread將QObject派生類移動到執行緒中即可。這是官方推薦的方法,而且使用靈活、簡單、安全可靠。如果執行緒要用到事件迴圈,使用繼承QObject的多執行緒方法無疑是一個更好的選擇。
這一期主要是說一下,子類化QObject+moveToThread的多執行緒使用方法以及一些注意問題,其中有很多細節的問題其實和往期《子類化QThread實現多執行緒》文章是一樣的,在這裡就不再多說了,不明白的可以到往期《子類化QThread實現多執行緒》文章找找答案。

一、步驟

  • 寫一個繼承QObject的類,將需要進行復雜耗時的邏輯封裝到槽函式中,作為執行緒的入口,入口可以有多個;
  • 在舊執行緒建立QObject派生類物件和QThread物件,最好使用堆分配的方式建立(new),並且最好不要為此兩個物件設定父類,便於後期程式的資源管理;
  • 把obj通過moveToThread方法轉移到新執行緒中,此時obj不能有任何的父類;
  • 把執行緒的finished訊號和obj物件、QThread物件的 QObject::deleteLater 槽連線,這個訊號槽必須連線,否則會記憶體洩漏;如果QObject的派生類和QThread類指標是需要重複使用,那麼就需要處理由物件被銷燬之前立即發出的 QObject::destroyed 訊號,將兩個指標設定為nullptr,避免出現野指標;
  • 將其他訊號與QObject派生類槽連線,用於觸發執行緒執行槽函式裡的任務;
  • 初始化完後呼叫 QThread::start() 來啟動執行緒,預設開啟事件迴圈;
  • 在邏輯結束後,呼叫 QThread::quit 或者 QThread::exit 退出執行緒的事件迴圈。

二、例項

寫一個繼承QObject的類:InheritQObject,程式碼如下:

#ifndef INHERITQOBJECT_H
#define INHERITQOBJECT_H

#include <QObject>
#include <QThread>
#include <QMutex>
#include <QMutexLocker>
#include <QDebug>

class InheritQObject : public QObject
{
    Q_OBJECT
public:
    explicit InheritQObject(QObject *parent = 0) : QObject(parent){

    }

    //用於退出執行緒迴圈計時的槽函式
    void StopTimer(){
        qDebug()<<"Exec StopTimer thread = "<<QThread::currentThreadId();
        QMutexLocker lock(&m_lock);
        m_flag = false;
    }

signals:
    void ValueChanged(int i);

public slots:
    void QdebugSlot(){
        qDebug()<<"Exec QdebugSlot thread = "<<QThread::currentThreadId();
    }

    //計時槽函式
    void TimerSlot(){
        qDebug()<<"Exec TimerSlot 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;
            }
        }
    }

private:
    bool m_flag;
    QMutex m_lock;
};

#endif // INHERITQOBJECT_H

mainwindow主視窗類,程式碼如下:

#ifndef MAINWINDOW_H
#define MAINWINDOW_H

#include <QMainWindow>
#include "ui_mainwindow.h"
#include "InheritQObject.h"
#include <QThread>

namespace Ui {
class MainWindow;
}

class MainWindow : public QMainWindow
{
    Q_OBJECT

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

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

        ui->setupUi(this);

        //建立QThread執行緒物件以及QObject派生類物件,注意:都不需要設定父類
        m_th = new QThread();
        m_obj = new InheritQObject();
        
        //改變m_obj的執行緒依附關係
        m_obj->moveToThread(m_th);

        //釋放堆空間資源
        connect(m_th, &QThread::finished, m_obj, &QObject::deleteLater);
        connect(m_th, &QThread::finished, m_th, &QObject::deleteLater);
        //設定野指標為nullptr
        connect(m_th, &QObject::destroyed, this, &MainWindow::SetPtrNullptr);
        connect(m_obj, &QObject::destroyed, this, &MainWindow::SetPtrNullptr);
        //連線其他訊號槽,用於觸發執行緒執行槽函式裡的任務
        connect(this, &MainWindow::StartTimerSignal, m_obj, &InheritQObject::TimerSlot);
        connect(m_obj, &InheritQObject::ValueChanged, this, &MainWindow::setValue);
        connect(this, &MainWindow::QdebugSignal, m_obj, &InheritQObject::QdebugSlot);

        //啟動執行緒,執行緒預設開啟事件迴圈,並且執行緒正處於事件迴圈狀態
        m_th->start();
    }

    ~MainWindow(){
        delete ui;
    }

signals:
    void StartTimerSignal();
    void QdebugSignal();

private slots:
    //觸發執行緒執行m_obj的計時槽函式
    void on_startBt_clicked(){
        emit StartTimerSignal();
    }

    //退出計時槽函式
    void on_stopBt_clicked(){
        m_obj->StopTimer();
    }

    //檢測執行緒狀態
    void on_checkBt_clicked(){
        if(m_th->isRunning()){
            ui->label->setText("Running");
        }else{
            ui->label->setText("Finished");
        }
    }

    void on_SendQdebugSignalBt_clicked(){
        emit QdebugSignal();
    }

    //退出執行緒
    void on_ExitBt_clicked(){
        m_th->exit(0);
    }

    //強制退出執行緒
    void on_TerminateBt_clicked(){
        m_th->terminate();
    }

    //消除野指標
    void SetPtrNullptr(QObject *sender){
        if(qobject_cast<QObject*>(m_th) == sender){
            m_th = nullptr;
            qDebug("set m_th = nullptr");
        }

        if(qobject_cast<QObject*>(m_obj) == sender){
            m_obj = nullptr;
            qDebug("set m_obj = nullptr");
        }
    }

    //響應m_obj發出的訊號來改變時鐘
    void setValue(int i){
            ui->lcdNumber->display(i);
    }

private:
    Ui::MainWindow *ui;
    QThread *m_th;
    InheritQObject *m_obj;
};

#endif // MAINWINDOW_H

通過以上的例項可以看到,我們無需重寫 QThread::run 函式,也無需顯式呼叫 QThread::exec 來啟動執行緒的事件迴圈了,通過QT原始碼可以知道,只要呼叫 QThread::start 它就會自動執行 QThread::exec 來啟動執行緒的事件迴圈。
子類化QThread實現多執行緒的建立方法,如果run函式裡面沒有死迴圈也沒有呼叫exec開啟事件迴圈的話,就算呼叫了 QThread::start 啟動執行緒,最終過一段時間,執行緒依舊是會退出,處於finished的狀態。那麼這種方式會出現這樣的情況嗎?我們直接執行上面的例項,然後過段時間檢查執行緒的狀態:

發現執行緒是一直處於執行狀態的。那接下來我們說一下應該怎麼正確使用這種方式建立的執行緒並正確退出執行緒和釋放資源。

三、如何正確使用執行緒(訊號槽)和建立執行緒資源

(1)如何正確使用執行緒?

如果需要讓執行緒去執行一些行為,那就必須要正確使用訊號槽的機制來觸發槽函式,其他的方式呼叫槽函式都只是在舊執行緒中執行,無法達到預想效果。在多執行緒中訊號槽的細節,會在後期的《跨執行緒的訊號槽》文章來講解,這裡我們先簡單說如何使用訊號槽來觸發執行緒執行任務先。
通過以上的例項得知,MainWindow 建構函式中使用了connect函式將 StartTimerSignal() 訊號和 InheritQObject::TimerSlot() 槽進行了繫結,程式碼語句如下:

connect(this, &MainWindow::StartTimerSignal, m_obj, &InheritQObject::TimerSlot);

當點選【startTime】按鈕發出 StartTimerSignal() 訊號時,這個時候就會觸發執行緒去執行 InheritQObject::TimerSlot() 槽函式進行計時。

由上面的列印資訊得知,InheritQObject::TimerSlot() 槽函式的確是在一個新的執行緒中執行了。在上面繼承QThread的多執行緒方法中也有說到,在這個時候去執行QThread::exit或者是QThread::quit是無效的,退出的訊號會一直掛在訊息佇列裡,只有點選了【stopTime】按鈕讓執行緒退出 while 迴圈,並且執行緒進入到事件迴圈 ( exec() ) 中,才會生效,並退出執行緒。

如果將【startTime】按鈕不是發出 StartTimerSignal() 訊號,而是直接執行InheritQObject::TimerSlot() 槽函式,會是怎麼樣的結果呢?程式碼修改如下:

//觸發執行緒執行m_obj的計時槽函式
void on_startBt_clicked(){
    m_obj->TimerSlot();
}

我們會發現介面已經卡死,InheritQObject::TimerSlot() 槽函式是在GUI主執行緒執行的,這就導致了GUI介面的事件迴圈無法執行,也就是介面無法被更新了,所以出現了卡死的現象。所以要使用訊號槽的方式來觸發執行緒工作才是有效的,不能夠直接呼叫obj裡面的成員函式。

(2)如何正確建立執行緒資源?

有一些資源我們可以直接在舊執行緒中建立(也就是不通過訊號槽啟動執行緒來建立資源),在新執行緒也可以直接使用,例如例項中的bool m_flag和QMutex m_lock變數都是在就執行緒中定義的,在新執行緒也可以使用。但是有一些資源,如果你需要在新執行緒中使用,那麼就必須要在新執行緒建立,例如定時器、網路套接字等,下面以定時器作為例子,程式碼按照下面修改:

/**********在InheritQObject類中新增QTimer *m_timer成員變數*****/
QTimer *m_timer;

/**********在InheritQObject建構函式建立QTimer例項*****/
m_timer = new QTimer();

/**********在InheritQObject::TimerSlot函式使用m_timer*****/
m_timer->start(1000);

執行點選【startTime】按鈕的時候,會出現以下報錯:

QObject::startTimer: Timers cannot be started from another thread

由此可知,QTimer是不可以跨執行緒使用的,所以將程式修改成如下,將QTimer的例項建立放到執行緒裡面建立:

/*********在InheritQObject類中新增Init的槽函式,將需要初始化建立的資源放到此處********/
public slots:
    void Init(){
        m_timer = new QTimer();
    }
    
/********在MainWindow類中新增InitSiganl()訊號,並繫結訊號槽***********/
//新增訊號
signals:
    void InitSiganl();
    
//在MainWindow建構函式新增以下程式碼
connect(this, &MainWindow::InitSiganl, m_obj, &InheritQObject::Init); //連線訊號槽
emit InitSiganl(); //發出訊號,啟動執行緒初始化QTimer資源

這樣QTimer定時器就屬於新執行緒,並且可以正常使用啦。網路套接字QUdpSocket、QTcpSocket等資源同理處理就可以了。

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

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

正確退出執行緒的方式,其實和往期《子類化QThread實現多執行緒》中《如何正確退出執行緒並釋放資源》小節所講到的差不多,就是要使用 quit 和 exit 來退出執行緒,避免使用 terminate 來強制結束執行緒,有時候會出現異常的情況。例如以上的例項,啟動之後,直接點選 【terminate】按鈕,介面就會出現卡死的現象。

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

在往期《子類化QThread實現多執行緒》中《如何正確退出執行緒並釋放資源》小節也有講到,千萬別手動delete QThread類或者派生類的執行緒指標,手動delete會發生不可預料的意外。理論上所有QObject都不應該手動delete,如果沒有多執行緒,手動delete可能不會發生問題,但是多執行緒情況下delete非常容易出問題,那是因為有可能你要刪除的這個物件在Qt的事件迴圈裡還排隊,但你卻已經在外面刪除了它,這樣程式會發生崩潰。所以需要 善用QObject::deleteLater 和 QObject::destroyed來進行記憶體管理。如上面例項使用到的程式碼:

//釋放堆空間資源
connect(m_th, &QThread::finished, m_obj, &QObject::deleteLater);
connect(m_th, &QThread::finished, m_th, &QObject::deleteLater);
//設定野指標為nullptr
connect(m_th, &QObject::destroyed, this, &MainWindow::SetPtrNullptr);
connect(m_obj, &QObject::destroyed, this, &MainWindow::SetPtrNullptr);

//消除野指標
void SetPtrNullptr(QObject *sender){
    if(qobject_cast<QObject*>(m_th) == sender){
        m_th = nullptr;
        qDebug("set m_th = nullptr");
    }

    if(qobject_cast<QObject*>(m_obj) == sender){
        m_obj = nullptr;
        qDebug("set m_obj = nullptr");
    }
}

當我們呼叫執行緒的 quit 或者 exit 函式,並且執行緒到達了事件迴圈的狀態,那麼執行緒就會在結束並且發出 QThread::finished 的訊號來觸發 QObject::deleteLater 槽函式,QObject::deleteLater就會銷燬系統為m_obj、m_th物件分配的資源。這個時候m_obj、m_th指標就屬於野指標了,所以需要根據QObject類或者QObject派生類物件銷燬時發出來的 QObject::destroyed 訊號來設定m_obj、m_th指標為nullptr,避免野指標的存在。
執行上面的例項,然後點選【exit】按鈕,結果如下圖:

五、小結

  • 這種QT多執行緒的方法,實現簡單、使用靈活,並且思路清晰,相對繼承於QThread類的方式更有可靠性,這種方法也是官方推薦的實現方法。如果執行緒要用到事件迴圈,使用繼承QObject的多執行緒方法無疑是一個更好的選擇;
  • 建立QObject派生類物件不能帶有父類;
  • 呼叫QThread::start是預設啟動事件迴圈;
  • 必須需要使用訊號槽的方式使用執行緒;
  • 需要注意跨線資源的建立,例如QTimer、QUdpSocket等資源,如果需要在子執行緒中使用,必須要在子執行緒建立;
  • 要善用QObject::deleteLater 和 QObject::destroyed來進行記憶體管理 ;
  • 儘量避免使用terminate強制退出執行緒,若需要退出執行緒,可以使用quit或exit;

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

相關文章