【QT】 Qt多執行緒的“那些事”

李春港發表於2020-11-13

一、前言

在我們開發Qt程式時,會經常用到多執行緒和訊號槽的機制,將耗時的事務放到單獨的執行緒,將其與GUI執行緒獨立開,然後通過訊號槽的機制來進行資料通訊,避免GUI介面假死的情況。例如:使用QT實現檔案的傳送,並且GUI介面需要實時顯示傳送的進度,這時就需要將耗時的檔案資料操作放到獨立的執行緒中,然後把已傳送的進度資料通過訊號傳送到GUI執行緒,GUI主執行緒接收到訊號後通過槽函式來更新UI,這樣介面就不會出現假死的情況了。
多執行緒訊號槽機制都是QT的關鍵技術之一。理解清楚這兩個技術點的關係,會讓你在開發過程中少走些彎路,少踩一些坑。本文章會介紹多種Qt多執行緒的實現方法,但是主要還是介紹有關於 訊號槽機制的多執行緒 實現方法。在學習QT多執行緒的"那些事"前,我們不妨先思考下以下的一些問題,然後再帶著問題繼續往下看,這樣可能會有更好的理解:
【1】如何正確使用QT的多執行緒?
【2】執行緒start後,哪裡才是執行緒正在啟動的地方?
【3】如何正確結束子執行緒以及資源釋放?
【4】重複呼叫QThread::start、QThread::quit()或QThread::exit()、QThread::terminate函式會有什麼影響?
【5】呼叫QThread::quit()或QThread::exit()、QThread::terminate函式會不會立刻停止執行緒?
【6】多執行緒之間是怎麼進行通訊的?
【7】如何在子執行緒中啟動訊號與槽的機制?
【8】QT中多執行緒之間的訊號和槽是如何傳送或執行的?
【9】如何正確使用訊號與槽機制?

接下來我會通過我以前踩過的坑和開發經驗,並且通過一些例項來總結一下QT多執行緒QT訊號槽機制的知識點。

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

二、QThread原始碼淺析

本章會挑出QThread原始碼中部分重點程式碼來說明QThread啟動到結束的過程是怎麼排程的。其次因為到了Qt4.4版本,Qt的多執行緒就有所變化,所以本章會以Qt4.0.1和Qt5.6.2版本的原始碼來進行淺析。

2.1 QThread類的定義原始碼

Qt4.0.1版本原始碼:

#ifndef QT_NO_THREAD
class Q_CORE_EXPORT QThread : public QObject
{
public:
    ...//省略
    explicit QThread(QObject *parent = 0);
    ~QThread();
    ...//省略
    void exit(int retcode = 0);

public slots:
    void start(QThread::Priority = InheritPriority); //啟動執行緒函式
    void terminate(); //強制退出執行緒函式
    void quit(); //執行緒退出函式
    ...//省略
signals:
    void started(); //執行緒啟動訊號
    void finished(); //執行緒結束訊號
    ...//省略
    
protected:
    virtual void run() = 0;
    int exec();
    ...//省略
};
#else // QT_NO_THREAD

Qt5.6.2版本原始碼:

#ifndef QT_NO_THREAD
class Q_CORE_EXPORT QThread : public QObject
{
    Q_OBJECT
public:
    ...//省略
    explicit QThread(QObject *parent = Q_NULLPTR);
    ~QThread();
    ...//省略
    void exit(int retcode = 0); //執行緒退出函式
    ...//省略
public Q_SLOTS:
    void start(Priority = InheritPriority); //啟動執行緒函式
    void terminate(); //強制退出執行緒函式
    void quit(); //執行緒退出函式
    ...//省略
Q_SIGNALS:
    void started(QPrivateSignal); //執行緒啟動訊號
    void finished(QPrivateSignal); //執行緒結束訊號
    
protected:
    virtual void run();
    int exec();
    ...//省略
};
#else // QT_NO_THREAD

從以上兩個版本的程式碼可以看出,這些函式在宣告上基本沒什麼差異,但是仔細看,兩個版本的 run() 函式宣告的是不是不一樣?

  • Qt4.0.1版本run() 函式是純虛擬函式,即此類為抽象類不可以建立例項,只可以建立指向該類的指標,也就是說如果你需要使用QThread來實現多執行緒,就必須實現QThread的派生類並且實現 run() 函式;
  • Qt5.6.2版本的run() 函式是虛擬函式,繼承QThread類時,可以重新實現 run() 函式,也可以不實現。

注:我檢視了多個Qt版本的原始碼,發現出現以上差異的版本是從Qt4.4開始的。從Qt4.4版本開始,QThread類就不再是抽象類了。

2.2 QThread::start()原始碼

再來看看QThread::start()原始碼,Qt4.0.1版本和Qt5.6.2版本此部分的原始碼大同小異,所以以Qt5.6.2版本的原始碼為主,如下:

void QThread::start(Priority priority)
{
    Q_D(QThread);
    QMutexLocker locker(&d->mutex);
 
    if (d->isInFinish) {
        locker.unlock();
        wait();
        locker.relock();
    }
 
    if (d->running)
        return;
        
    ... ... // 此部分是d指標配置
 
#ifndef Q_OS_WINRT

    ... ... // 此部分為註釋
    
    d->handle = (Qt::HANDLE) _beginthreadex(NULL, d->stackSize, QThreadPrivate::start,
                                            this, CREATE_SUSPENDED, &(d->id));
#else // !Q_OS_WINRT
    d->handle = (Qt::HANDLE) CreateThread(NULL, d->stackSize, (LPTHREAD_START_ROUTINE)QThreadPrivate::start,
                                            this, CREATE_SUSPENDED, reinterpret_cast<LPDWORD>(&d->id));
#endif // Q_OS_WINRT
 
    if (!d->handle) {
        qErrnoWarning(errno, "QThread::start: Failed to create thread");
        d->running = false;
        d->finished = true;
        return;
    }
 
    int prio;
    d->priority = priority;
    switch (d->priority) {
    
    ... ... // 此部分為執行緒優先順序配置
    
    case InheritPriority:
    default:
        prio = GetThreadPriority(GetCurrentThread());
        break;
    }
 
    if (!SetThreadPriority(d->handle, prio)) {
        qErrnoWarning("QThread::start: Failed to set thread priority");
    }
 
    if (ResumeThread(d->handle) == (DWORD) -1) {
        qErrnoWarning("QThread::start: Failed to resume new thread");
    }
}

挑出裡面的重點來說明:

(1)Q_D()巨集定義

在看原始碼的時候,當時比較好奇start函式的第一條語句 Q_D()巨集定義 是什麼意思,所以就看了下原始碼,在此也順便講講,Q_D() 原始碼是一個巨集定義,如下:

#define Q_D(Class) Class##Private * const d = d_func()

此處利用了預處理巨集裡的 ## 操作符:連線前後兩個符號,變成一個新的符號。將Q_D(QThread)展開後,變成:QThreadPrivate * const d = d_func()。

(2)_beginthreadex()函式
上面d->handle = (Qt::HANDLE) _beginthreadex ( NULL, d->stackSize, QThreadPrivate::start, this, CREATE_SUSPENDED, &( d->id ) ) 語句中的函式是建立執行緒的函式,其原型以及各引數的說明如下:

unsigned long _beginthreadex( 
 
void *security,       // 安全屬性,NULL為預設安全屬性
 
unsigned stack_size,  // 指定執行緒堆疊的大小。如果為0,則執行緒堆疊大小和建立它的執行緒的相同。一般用0
 
unsigned ( __stdcall *start_address )( void * ), 
                      // 指定執行緒函式的地址,也就是執行緒呼叫執行的函式地址(用函式名稱即可,函式名稱就表示地址)
 
void *arglist,        // 傳遞給執行緒的引數的指標,可以通過傳入物件的指標,線上程函式中再轉化為對應類的指標
                        //如果傳入this,這個this表示呼叫QThread::start的物件地址,也就是QThread或者其派生類物件本身
 
unsigned initflag,    // 執行緒初始狀態,0:立即執行;CREATE_SUSPEND:suspended(懸掛)
 
unsigned *thrdaddr    // 用於記錄執行緒ID的地址
 
);

2.3 QThreadPrivate::start()原始碼

從QThread::start()原始碼可以知道,QThreadPrivate::start是重點,其實際就是呼叫了QThreadPrivate::start(this),這個 this 表示呼叫QThread::start的物件地址,也就是QThread或者其派生類物件本身。因為兩個Qt版本此部分的原始碼大同小異,所以本部分主要是以5.6.2版本的原始碼為主,其原始碼以及說明如下:

// 引數arg就是上面所說的this
unsigned int __stdcall QT_ENSURE_STACK_ALIGNED_FOR_SSE QThreadPrivate::start(void *arg)
{
    QThread *thr = reinterpret_cast<QThread *>(arg);
    QThreadData *data = QThreadData::get2(thr);
 
    // 建立執行緒區域性儲存變數,存放執行緒id
    qt_create_tls();
    TlsSetValue(qt_current_thread_data_tls_index, data);
    data->threadId = reinterpret_cast<Qt::HANDLE>(quintptr(GetCurrentThreadId()));
 
    QThread::setTerminationEnabled(false);
 
    {
        QMutexLocker locker(&thr->d_func()->mutex);
        data->quitNow = thr->d_func()->exited;
    }
 
    if (data->eventDispatcher.load()) // custom event dispatcher set?
        data->eventDispatcher.load()->startingUp();
    else
        createEventDispatcher(data);
        
    ...//省略
    
    emit thr->started(QThread::QPrivateSignal()); // 發射執行緒啟動訊號
    QThread::setTerminationEnabled(true);
    thr->run(); // 呼叫QThread::run()函式 -- 執行緒函式
 
    finish(arg); //結束執行緒
    return 0;
}

由上述原始碼可以看出,實際上 run() 函式是在這裡呼叫的,並且發出了 started() 啟動訊號,等到 run() 函式執行完畢,最後是呼叫了 QThreadPrivate::finish 函式結束執行緒,並且在finish內會發出 QThread::finished() 執行緒已結束的訊號。

2.4 QThread::run()原始碼

再看看QThread::run()函式的原始碼。在上面 《2.1 QThread類的定義原始碼》的小節,我們可以看到兩個Qt版本宣告此方法的方式不一樣,Qt-4.0版本將此定義為了純虛擬函式,而Qt-5.6版本將此定義為了虛擬函式,那我們就看看Qt-5.6版本中,QThread::run()是如何定義的,如下:

void QThread::run()
{
    (void) exec();
}
  1. 每一個 Qt 應用程式至少有一個 事件迴圈 ,就是呼叫了 QCoreApplication::exec() 的那個事件迴圈。不過,QThread也可以開啟事件迴圈。只不過這是一個受限於執行緒內部的事件迴圈。因此我們將處於呼叫main()函式的那個執行緒,並且由 QCoreApplication::exec() 建立開啟的那個事件迴圈成為 主事件迴圈 ,或者直接叫 主迴圈 。注意,QCoreApplication::exec()只能在呼叫main()函式的執行緒呼叫。主迴圈所在的執行緒就是主執行緒,也被成為 GUI 執行緒,因為所有有關 GUI 的操作都必須在這個執行緒進行。QThread的區域性事件迴圈則可以通過在 QThread::run() 中呼叫 QThread::exec() 開啟。

  2. 我們通過以上原始碼可以看到,它的定義很簡單,就是呼叫了一個函式:QThread::exec() 開啟執行緒中的 事件迴圈 ,我們也可以通過繼承QThread,重寫run()函式的方式,讓其實現相對複雜的邏輯程式碼。如果你的執行緒需要將某些槽函式在本執行緒完成的話,就必須開啟事件迴圈,否則線上程內無法響應各種訊號並作出相應的行為。

小結: 比Qt-4.4版本更早的版本中,我們使用QThread啟動執行緒時,就必須要實現繼承於QThread的派生類,並且一定要重寫run函式,若需要使用事件迴圈,就需要在run函式中新增exec()。到了Qt4.4版本之後(包括Qt4.4版本),QThread就不是抽象類了,不派生也可以例項化,在不重寫QThread::run()方法,start啟動執行緒是預設啟動事件迴圈的。

注:當程式跑到了exec()程式碼時,位於exec()後面的程式碼就不會再被執行,除非我們使用quit、exit等退出語句來退出事件迴圈,退出後,程式才會繼續執行位於exec()後面的程式碼。

2.5 QThread::quit()、QThread::exit()、QThread::terminate()原始碼

執行緒停止函式的區別,從Qt原始碼來分析:

(1)QThread::quit()、QThread::exit()

//QThread::quit()宣告
void quit();
//QThread::quit()定義
void QThread::quit()
{ exit(); }

//QThread::exit()宣告
void exit(int retcode = 0);
//QThread::exit()定義
void QThread::exit(int returnCode)
{
    Q_D(QThread);
    QMutexLocker locker(&d->mutex);
    d->exited = true;
    d->returnCode = returnCode;
    d->data->quitNow = true;
    for (int i = 0; i < d->data->eventLoops.size(); ++i) {
        QEventLoop *eventLoop = d->data->eventLoops.at(i);
        eventLoop->exit(returnCode);
    }
}

由以上原始碼可知,QThread::quit()QThread::exit(0) 的呼叫是等效的,都是告訴執行緒的事件迴圈,以返回碼0(成功)退出。如果執行緒沒有事件,則此函式不執行任何操作,也就是無效的。當執行緒擁有事件迴圈並且正處於 事件迴圈(QThread::exec()) 的狀態時,呼叫 QThread::quit()或者QThread::exit() 執行緒就會馬上停止,否則不會立刻停止執行緒,直到執行緒處於事件迴圈也就是正在執行 QThread::exec() 時,才會停止執行緒。

如果重複呼叫 QThread::quit()或者QThread::exit() 會有什麼影響嗎?
重複呼叫 QThread::quit()或者QThread::exit() 也不會有什麼影響,因為只有擁有事件迴圈的執行緒,這兩個函式才會生效停止執行緒的功能。

(2)QThread::terminate()

void QThread::terminate()
{
    Q_D(QThread);
    QMutexLocker locker(&d->mutex);
    if (!d->running)
        return;
    if (!d->terminationEnabled) {
        d->terminatePending = true;
        return;
    }

// Calling ExitThread() in setTerminationEnabled is all we can do on WinRT
#ifndef Q_OS_WINRT
    TerminateThread(d->handle, 0);
#endif
    QThreadPrivate::finish(this, false); //結束執行緒函式
}

在這個函式定義的最後一個語句,是呼叫了 QThreadPrivate::finish(this, false); 函式,其函式作用是直接退出執行緒,無論執行緒是否開啟了事件迴圈都會生效,會馬上終止一個執行緒,但這個函式存在非常不安定因素,不推薦使用

如果重複呼叫 QThread::terminate() 會有什麼影響嗎?
沒有影響。我們可以看到函式體裡面的第三條語句,它首先會判斷執行緒是否還在執行中,如果不是,會直接退出函式,就不會繼續往下執行呼叫QThreadPrivate::finish(this, false); 函式了。

2.6 章節小結

相信看了以上的一些QThread原始碼,都大概知道了QThread類的本質以及QThread開啟到結束的過程。這裡我再簡單總結下:

(1)QThread的本質:

  • QThread 是用來管理執行緒的,它所依附的執行緒和它管理的執行緒並不是同一個東西;
  • QThread 所依附的執行緒,就是執行 QThread t 或 QThread * t=new QThread 所在的執行緒;
  • QThread 管理的執行緒,就是 run 啟動的執行緒,也就是次執行緒。

(2)在這裡針對Qt4.4版本之後(包括Qt4.4版本)簡單彙總一下執行緒啟動到結束的過程:

  • QThread物件或者QThread派生類物件顯式呼叫QThread類中的外部start()方法;
  • QThread::start()方法再呼叫QThreadPrivate::start()方法;
  • 在QThreadPrivate::start()方法內呼叫了QThread::run()虛擬函式,對使用者來說到了這裡才是真正進入了一個新的執行緒裡面。也就是說定義QThread物件或者QThread派生類物件的時候,還是在原來的執行緒裡面,只有進入run函式才是進入了新的執行緒;
  • 在QThreadPrivate::start()方法呼叫QThread::run()虛擬函式結束後,就會繼續呼叫QThreadPrivate::finish()函式來結束執行緒,併發出執行緒結束的訊號finished()。

(3)QThread::quit()、QThread::exit()、QThread::terminate():

  • 對執行緒重複使用這三個停止執行緒的函式,沒有任何影響;
  • 儘量不要使用QThread::terminate()停止執行緒,此方式是強制退出執行緒,沒有安全保障。
  • 呼叫QThread::quit()和QThread::exit()一樣。

(4)Qt各版本QThread類的變化:

  • Qt4.4版本之前QThread類是屬於抽象類, Qt4.4版本之後(包括4.4版本)不是抽象類。

三、四種Qt多執行緒的實現方法

Qt的多執行緒實現方法主要有四種形式:子類化QThread、子類化QObject+moveToThread、繼承QRunnable+QThreadPool、QtConcurrent::run()+QThreadPool。本文章會注重介紹前兩種實現方法:子類化QThread、子類化QObject+moveToThread,也會簡單介紹後兩種的使用。
注:QtConcurrent、QRunnable以及QThreadPool的類,在Qt-4.4版本才開始有。

3.1 子類化QThread

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

3.1.1 步驟

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

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

3.1.2 不使用事件迴圈例項

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 ,不使用事件迴圈的執行緒使用就實現了,就這麼簡單。

3.1.3 使用事件迴圈例項

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槽函式是在子執行緒中執行的。

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

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

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

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

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

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

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

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

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

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()退出執行緒函式。由《2.5 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()】按鈕的時候就會立刻退出當前執行緒。由《2.5 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] 這個槽,這個槽非常有用,後面會經常用到它用於安全的執行緒資源銷燬。我們通過以上的《2.3 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》的方法。

3.1.6 小結

  • 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多執行緒的方法適用於後臺執行長時間的耗時操作、單任務執行的、無需線上程內執行槽的情景。

3.2 子類化QObject+moveToThread

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

3.2.1 步驟

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

3.2.2 例項

寫一個繼承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的狀態。那麼這種方式會出現這樣的情況嗎?我們直接執行上面的例項,然後過段時間檢查執行緒的狀態:

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

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

(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等資源同理處理就可以了。

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

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

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

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

在上面《3.1.5 如何正確退出執行緒並釋放資源》小節也有講到,千萬別手動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】按鈕,結果如下圖:

3.2.5 小結

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

3.3 繼承QRunnable+QThreadPool

此方法個人感覺使用的相對較少,在這裡只是簡單介紹下使用的方法。我們可以根據使用的場景來選擇方法。

此方法和QThread的區別:

  • 與外界通訊方式不同。由於QThread是繼承於QObject的,但QRunnable不是,所以在QThread執行緒中,可以直接將執行緒中執行的結果通過訊號的方式發到主程式,而QRunnable執行緒不能用訊號槽,只能通過別的方式,等下會介紹;
  • 啟動執行緒方式不同。QThread執行緒可以直接呼叫start()函式啟動,而QRunnable執行緒需要藉助QThreadPool進行啟動;
  • 資源管理不同。QThread執行緒物件需要手動去管理刪除和釋放,而QRunnable則會在QThreadPool呼叫完成後自動釋放。

接下來就來看看QRunnable的用法、使用場景以及注意事項;

3.3.1 步驟

要使用QRunnable建立執行緒,步驟如下:

  • 繼承QRunnable。和QThread使用一樣, 首先需要將你的執行緒類繼承於QRunnable;
  • 重寫run函式。還是和QThread一樣,需要重寫run函式;
  • 使用QThreadPool啟動執行緒。

3.3.2 例項

繼承於QRunnable的類:

#ifndef INHERITQRUNNABLE_H
#define INHERITQRUNNABLE_H

#include <QRunnable>
#include <QWidget>
#include <QDebug>
#include <QThread>

class CusRunnable : public QRunnable
{
public:
    explicit CusRunnable(){
    }

    ~CusRunnable(){
        qDebug() << __FUNCTION__;
    }

    void run(){
        qDebug() << __FUNCTION__ << QThread::currentThreadId();
        QThread::msleep(1000);
    }
};

#endif // INHERITQRUNNABLE_H

主介面類:

#ifndef MAINWINDOW_H
#define MAINWINDOW_H

#include <QMainWindow>
#include "ui_mainwindow.h"
#include "InheritQRunnable.h"
#include <QThreadPool>
#include <QDebug>

namespace Ui {
class MainWindow;
}

class MainWindow : public QMainWindow
{
    Q_OBJECT

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

        m_pRunnable = new CusRunnable();
        qDebug() << __FUNCTION__  << QThread::currentThreadId();
        QThreadPool::globalInstance()->start(m_pRunnable);
    }

    ~MainWindow(){
        qDebug() << __FUNCTION__ ;
        delete ui;
    }

private:
    Ui::MainWindow *ui;
    CusRunnable * m_pRunnable = nullptr;
};

#endif // MAINWINDOW_H

直接執行以上例項,結果輸出如下:

MainWindow 0x377c
run 0x66ac
~CusRunnable

我們可以看到這裡列印的執行緒ID是不同的,說明是在不同執行緒中執行,而執行緒執行完後就自動進入到解構函式中, 不需要手動釋放。

3.3.3 啟動執行緒的方式

上面我們說到要啟動QRunnable執行緒,需要QThreadPool配合使用,而呼叫方式有兩種:全域性執行緒池和非全域性執行緒池。

(1)使用全域性執行緒池啟動

QThreadPool::globalInstance()->start(m_pRunnable);

(2)使用非全域性執行緒池啟動

該方式可以控制執行緒最大數量, 以及其他設定,比較靈活,具體參照幫助文件。

QThreadPool	  threadpool;
threadpool.setMaxThreadCount(1);
threadpool.start(m_pRunnable);

3.3.4 如何與外界通訊

前面我們提到,因為QRunnable沒有繼承於QObject,所以沒法使用訊號槽與外界通訊,那麼,如果要在QRunnable執行緒中和外界通訊怎麼辦呢,通常有兩種做法:

  • 使用多繼承。讓我們的自定義執行緒類同時繼承於QRunnable和QObject,這樣就可以使用訊號和槽,但是多執行緒使用比較麻煩,特別是繼承於自定義的類時,容易出現介面混亂,所以在專案中儘量少用多繼承。
  • 使用QMetaObject::invokeMethod。

接下來只介紹使用QMetaObject::invokeMethod來通訊:

QMetaObject::invokeMethod 函式定義如下:

static bool QMetaObject::invokeMethod(
                         QObject *obj, const char *member,
                         Qt::ConnectionType,
                         QGenericReturnArgument ret,
                         QGenericArgument val0 = QGenericArgument(Q_NULLPTR),
                         QGenericArgument val1 = QGenericArgument(),
                         QGenericArgument val2 = QGenericArgument(),
                         QGenericArgument val3 = QGenericArgument(),
                         QGenericArgument val4 = QGenericArgument(),
                         QGenericArgument val5 = QGenericArgument(),
                         QGenericArgument val6 = QGenericArgument(),
                         QGenericArgument val7 = QGenericArgument(),
                         QGenericArgument val8 = QGenericArgument(),
                         QGenericArgument val9 = QGenericArgument());

該函式就是嘗試呼叫obj的member函式,可以是訊號、槽或者Q_INVOKABLE宣告的函式(能夠被Qt元物件系統喚起),只需要將函式的名稱傳遞給此函式,呼叫成功返回true,失敗返回false。member函式呼叫的返回值放在ret中,如果呼叫是非同步的,則不能計算返回值。你可以將最多10個引數(val0、val1、val2、val3、val4、val5、val6、val7、val8和val9)傳遞給member函式,必須使用Q_ARG()和Q_RETURN_ARG()巨集封裝引數,Q_ARG()接受型別名 + 該型別的常量引用;Q_RETURN_ARG()接受一個型別名 + 一個非常量引用。

QMetaObject::invokeMethod可以是非同步呼叫,也可以是同步呼叫。這取決與它的連線方式Qt::ConnectionType type:

  • 如果型別是Qt::DirectConnection,則會立即呼叫該成員,同步呼叫。
  • 如果型別是Qt::QueuedConnection,當應用程式進入主事件迴圈時,將傳送一個QEvent並呼叫該成員,非同步呼叫。
  • 如果型別是Qt::BlockingQueuedConnection,該方法將以與Qt::QueuedConnection相同的方式呼叫,不同的地方:當前執行緒將阻塞,直到事件被傳遞。使用此連線型別在同一執行緒中的物件之間通訊將導致死鎖。
  • 如果型別是Qt::AutoConnection,如果obj與呼叫者在同一執行緒,成員被同步呼叫;否則,它將非同步呼叫該成員。

我們在主介面中定一個函式,用於更新介面內容:

Q_INVOKABLE void setText(QString msg){
    ui->label->setText(msg);
}

繼承於QRunnable的執行緒類,修改完成如下:

#ifndef INHERITQRUNNABLE_H
#define INHERITQRUNNABLE_H

#include <QRunnable>
#include <QWidget>
#include <QDebug>
#include <QThread>

class CusRunnable : public QRunnable
{
public:
    //修改建構函式
    explicit CusRunnable(QObject *obj):m_pObj(obj){
    }

    ~CusRunnable(){
        qDebug() << __FUNCTION__;
    }

    void run(){
        qDebug() << __FUNCTION__ << QThread::currentThreadId();
        QMetaObject::invokeMethod(m_pObj,"setText",Q_ARG(QString,"hello world!")); //此處與外部通訊
        QThread::msleep(1000);
    }

private:
    QObject * m_pObj = nullptr; //定義指標
};

#endif // INHERITQRUNNABLE_H

建立執行緒物件時,需要將主介面物件傳入執行緒類,如下:

m_pRunnable = new CusRunnable(this);

到這裡也就實現了執行緒與外部通訊了,執行效果如下:

3.3.5 小結

  • 使用該方法實現的多執行緒,執行緒中的資源無需使用者手動釋放,執行緒執行完後會自動回收資源;
  • 和繼承QThread的方法一樣需要繼承類,並且重新實現run函式;
  • 需要結合QThreadPool執行緒池來使用;
  • 與外界通訊可以使用如果使用訊號槽機制會比較麻煩,可以使用QMetaObject::invokeMethod的方式與外界通訊。

3.4 QtConcurrent::run()+QThreadPool

在QT開發的場景中,個人覺得此方法使用的也比較少,所以本文只作一個簡單使用的介紹。QtConcurrent 是名稱空間 (namespace),它提供了高層次的函式介面 (APIs),使所寫程式,可根據計算機的 CPU 核數,自動調整執行的執行緒數目。本文以 Qt 中的 QtConcurrent::run() 函式為例,介紹如何將函式執行在單獨的執行緒中。

(1)使用 QtConcurrent 模組,需要在 .pro 中新增:

QT += concurrent

(2)將一個普通函式執行在單獨執行緒:

#include <QApplication>
#include <QDebug>
#include <QThread>
#include <QtConcurrent>

void fun1(){
    qDebug()<<__FUNCTION__<<QThread::currentThread();
}

void fun2(QString str1, QString str2){
    qDebug()<<__FUNCTION__<<str1+str2<<QThread::currentThread();
}

int fun3(int i, int j){
    qDebug()<<__FUNCTION__<<QThread::currentThread();
    return i+j;
}

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

    qDebug()<<__FUNCTION__<<QThread::currentThread();

    //無引數的普通函式
    QFuture<void> fut1 = QtConcurrent::run(fun1);

    //有引數的普通函式
    QFuture<void> fut2 = QtConcurrent::run(fun2, QString("Thread"),QString(" 2"));

    //獲取普通函式的返回值
    int i=1, j=2;
    QFuture<int> fut3 = QtConcurrent::run(fun3, i, j);
    qDebug()<<"ret:"<<fut3.result();

    //以上的例子,如果要為其指定執行緒池,可以將執行緒池的指標作為第一個引數傳遞進去
    QThreadPool pool;
    QFuture<void> fut4 = QtConcurrent::run(&pool, fun1);

    fut1.waitForFinished();
    fut2.waitForFinished();
    fut3.waitForFinished();
    fut4.waitForFinished();

    return a.exec();
}

輸出結果:

qMain QThread(0xf380590)
fun2 "Thread 2" QThread(0x1ca7c758, name = "Thread (pooled)")
fun1 QThread(0x1ca7c6d8, name = "Thread (pooled)")
fun3 QThread(0x1ca7c5b8, name = "Thread (pooled)")
ret: 3
fun1 QThread(0x1ca7c438, name = "Thread (pooled)")

(3)將類中的成員函式單獨執行線上程中:

將類中的成員函式執行在某一個執行緒中,可將指向該類例項的引用或指標作為 QtConcurrent::run 的第一個引數傳遞進去,常量成員函式一般傳遞常量引用 (const reference),而非常量成員函式一般傳遞指標 (pointer)。

  • 常量成員函式

在一個單獨的執行緒中,呼叫 QByteArray 的常量成員函式 split(),傳遞給 run() 函式的引數是 bytearray

//常量成員函式QByteArray::split()
QByteArray bytearray = "hello,world";
QFuture<QList<QByteArray> > future = QtConcurrent::run(bytearray, &QByteArray::split, ',');
QList<QByteArray> result = future.result();
qDebug()<<"result:"<<result;
  • 非常量成員函式

在一個單獨的執行緒中,呼叫 QImage 的非常量成員函式 invertPixels(),傳遞給 run() 函式的引數是 &image

QImage image = ...;
QFuture<void> future = QtConcurrent::run(&image, &QImage::invertPixels, QImage::InvertRgba);
...
future.waitForFinished();  // At this point, the pixels in 'image' have been inverted

四、跨執行緒的訊號槽

執行緒的訊號槽機制需要開啟執行緒的事件迴圈機制,即呼叫QThread::exec()函式開啟執行緒的事件迴圈。

Qt訊號-槽連線函式原型如下:

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

Qt支援5種連線方式

  • Qt::DirectConnection(直連方式)(訊號與槽函式關係類似於函式呼叫,同步執行)
    當訊號發出後,相應的槽函式將立即被呼叫。emit語句後的程式碼將在所有槽函式執行完畢後被執行。當訊號發射時,槽函式將直接被呼叫。無論槽函式所屬物件在哪個執行緒,槽函式都在發射訊號的執行緒內執行。
  • Qt::QueuedConnection(佇列方式)(此時訊號被塞到事件佇列裡,訊號與槽函式關係類似於訊息通訊,非同步執行)
    當訊號發出後,排隊到訊號佇列中,需等到接收物件所屬執行緒的事件迴圈取得控制權時才取得該訊號,呼叫相應的槽函式。emit語句後的程式碼將在發出訊號後立即被執行,無需等待槽函式執行完畢。當控制權回到接收者所依附執行緒的事件迴圈時,槽函式被呼叫。槽函式在接收者所依附執行緒執行。
  • Qt::AutoConnection(自動方式) 
    Qt的預設連線方式,如果訊號的發出和接收訊號的物件同屬一個執行緒,那個工作方式與直連方式相同;否則工作方式與佇列方式相同。如果訊號在接收者所依附的執行緒內發射,則等同於直接連線如果發射訊號的執行緒和接受者所依附的執行緒不同,則等同於佇列連線
  • Qt::BlockingQueuedConnection(訊號和槽必須在不同的執行緒中,否則就產生死鎖) 
    槽函式的呼叫情形和Queued Connection相同,不同的是當前的執行緒會阻塞住,直到槽函式返回。
  • Qt::UniqueConnection
    與預設工作方式相同,只是不能重複連線相同的訊號和槽,因為如果重複連線就會導致一個訊號發出,對應槽函式就會執行多次。

如果沒有特殊的要求我們connect函式選擇預設的連線方式就好,也就是connect的第五個引數不填寫就ok,例如:

connect(m_obj, &QObject::destroyed, this, &MainWindow::SetPtrNullptr);

五、總結

本文章分析了部分QThread原始碼,講解了四種QT多執行緒的實現方法,以及多執行緒訊號槽連線的知識點。接下來我再簡單對以上四種QT多執行緒的實現方法,總結一下哪種情況該使用哪種 Qt 執行緒技術:

需要執行緒的生命週期 開發場景 解決方案
單次呼叫 在其他的執行緒中執行一個方法,當方法執行結束後退出執行緒。 (1)編寫一個函式,然後利用 QtConcurrent::run()執行它;(2)從QRunnable 派生一個類,並利用全域性執行緒池QThreadPool::globalInstance()->start()來執行它。(3) 從QThread派生一個類, 過載QThread::run() 方法並使用QThread::start()來執行它。
單次呼叫 一個耗時的操作必須放到另一個執行緒中執行。在這期間,狀態資訊必須傳送到GUI執行緒中。 使用 QThread,,過載run方法並根據情況傳送訊號。.使用queued訊號/槽連線來連線訊號與GUI執行緒的槽。
常駐 有一物件位於另一個執行緒中,將讓其根據不同的請求執行不同的操作。這意味與工作者執行緒之間的通訊是必須的。 從QObject 派生一個類並實現必要的槽和訊號,將物件移到一個具有事件迴圈的執行緒中,並通過queued訊號/槽連線與物件進行通訊。

當然QT還有其他實現多執行緒的方法,例如使用QtConcurrent::map()函式、QSocketNotifier,具體怎麼使用,這裡就不再過多介紹了。

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

相關文章