QT從入門到入土(四)——多執行緒

唯有自己強大發表於2021-07-20

引言

前面幾篇已經對C++的執行緒做了簡單的總結,淺談C++11中的多執行緒(三) - 唯有自己強大 - 部落格園 (cnblogs.com)。本篇著重於Qt多執行緒的總結與實現。

跟C++11中很像的是,Qt中使用QThread來管理執行緒,一個QThread物件管理一個執行緒,在使用上有很多跟C++11中相似的地方,但更多的是Qt中獨有的內容。另外,QThread物件也有訊息迴圈exec()函式,即每個執行緒都有一個訊息迴圈,用來處理自己這個執行緒的事件。


一,知識回顧

首先先來回顧一下一些知識點:

1,為什麼需要多執行緒?

解決耗時操作堵塞整個程式的問題,一般我們會將耗時的操作放入子執行緒中

2,程式和執行緒的區別:

程式:一個獨立的程式,擁有獨立的虛擬地址空間,要和其他程式通訊,需要使用程式通訊的機制。

執行緒:沒有自己的資源,都是共享程式的虛擬地址空間,多個執行緒通訊存在隱患。

ps:在作業系統每一個程式都擁有獨立的記憶體空間,執行緒的開銷遠小於程式,一個程式可以擁有多個執行緒。(因此我們常用多執行緒併發,而非多程式併發)

為了更容易理解多執行緒的作用,先看一個例項:

在主執行緒中執行一個10s耗時的操作。(通過按鈕來觸發)

void Widget::on_pushButton_clicked()
{
 QThread::sleep(10);
}

可以看到程式執行過程中,整個執行緒都在響應10秒的耗時操作,對於執行緒的訊息迴圈exec()函式就未響應了(就是你在這個過程中拖動介面是無反應的)

 二,Qt中實現多執行緒的兩種方法

??2.1.派生QThread類物件的方法(重寫Run函式)

首先,以文字形式來說明需要哪幾個步驟。

  1. 自定義一個自己的類,使其繼承自QThread類;
  2. 在自定義類中覆寫QThread類中的虛擬函式run()。

這很可能就是C++中多型的使用。補充一點:QThread類繼承自QObject類。

這裡要重點說一下run()函式了。它作為執行緒的入口,也就是執行緒從run()開始執行,我們打算線上程中完成的工作都要寫在run()函式中,個人認為可以把run()函式理解為執行緒函式。這也就是子類覆寫基類的虛擬函式,基類QThread的run()函式只是簡單啟動exec()訊息迴圈,關於這個exec()後面有很多東西要講,請做好準備。
那麼我們就來嘗試用多執行緒實現10s耗時的操作:(用按鈕觸發)

1️⃣在編輯好ui介面後,先建立一個workThread1的類。(繼承自QThread類(可以先繼承Qobject再去改成QThread))

2️⃣在workThread1的類中重寫run函式

在workThread1.h的public類宣告run函式: void run();

在workThread1.cpp中重寫run函式(列印子執行緒的ID):

#include "workthread1.h"
#include<QDebug>
workThread1::workThread1(QObject *parent) : QThread(parent)
{

}
//重寫run函式
void workThread1::run()
{
    qDebug()<<"當前執行緒ID:"<<QThread::currentThreadId();
    qDebug()<<"開始執行執行緒";
     QThread::sleep(10);
     qDebug()<<"執行緒結束";
     
}

3️⃣在widget.cpp中的button的click事件中列印主執行緒ID:

void Widget::on_pushButton_clicked()
{
 qDebug()<<"當前執行緒ID:"<<QThread::currentThreadId();
}

4️⃣啟動子執行緒

在widget.h的private中宣告執行緒 workThread1 *thread1;(需新增#include<workthread1.h>)

在widget.cpp中初始化該執行緒,並啟動:

#include "widget.h"
#include "ui_widget.h"
#include<QThread>
#include<QDebug>
Widget::Widget(QWidget *parent)
    : QWidget(parent)
    , ui(new Ui::Widget)
{
    ui->setupUi(this);
   thread1=new workThread1(this);//初始化子執行緒

}

Widget::~Widget()
{
    delete ui;
}


void Widget::on_pushButton_clicked()
{
 qDebug()<<"當前執行緒ID:"<<QThread::currentThreadId();
 thread1->start();//啟動子執行緒
}

可以實現,在執行耗時操作時也可拖動介面。

 

需要注意的是:

使用QThread::currentThreadId()來檢視當前執行緒的ID,無論是子執行緒還是主執行緒,不同執行緒其ID是不同的。注意,這是一個靜態函式,因此可以不經過物件來呼叫。

建立的workThread1類的執行實際上是在主執行緒裡的,只有run函式內的程式才會在子執行緒中執行!(即QThread只是執行緒的管理類,只有run()才是我們的執行緒函式)

因此在QThread(即建立的類)中的成員變數屬於主執行緒,在訪問前需要判斷訪問是否安全。run()中建立的變數屬於子執行緒。

執行緒之間共享記憶體是不安全的(由於多執行緒爭奪資源會影響資料安全問題),解決的辦法就是要上鎖。


關於互斥鎖

互斥鎖是一種簡單的加鎖的方法來控制對共享資源的訪問。只要某一個執行緒上鎖了,那麼就會強行霸佔公共資源的訪問權,其他的執行緒無法訪問直到這個執行緒解鎖了,從而保護共享資源。

在Qt中的互斥鎖常用兩種方式:

  • QMutex類下的lock(上鎖)和unlcok(解鎖)
//需要在標頭檔案中引用#include<QMutex>
//並在標頭檔案的private中宣告QMutex mutex;


mutex.lock() public_value++;//公共成員變數 mutex.unlock();
  • QMutexLocker類下的lock(上鎖後,當執行解構函式時會自動解鎖)
//需要在標頭檔案中引用#include<QMutexLocker>和include<QMutex>
//並在標頭檔案的private中宣告QMutex mutex;

QMutexLocker lock(&mutex);//執行建構函式時執行mutex.lock()
public_value++;

//執行解構函式時執行mutex.unlock()

 


關於exec()訊息迴圈

個人認為,exec()這個點太重要了,同時還不太容易理解。

比如下面的程式碼中有兩個exec(),我們講“一山不容二虎”,放在這裡就是說,一個執行緒中不能同時執行兩個exec(),否則就會造成另一個訊息迴圈得不到訊息。像QDialog模態視窗中的exec()就是因為在主執行緒中同時開了兩個exec(),導致主視窗的exec()接收不到使用者的訊息了。但是!但是!但是!我們這裡卻沒有任何問題,因為它們沒有出現在同一個執行緒中,一個是主執行緒中的exec(),一個是子執行緒中的exec()。

#include <QApplication>
#include <QThread>
#include <QDebug>
 
class MyThread:public QThread
{
    public:
        void run()
        {
            qDebug()<<"child thread begin"<<endl;
            qDebug()<<"child thread"<<QThread::currentThreadId()<<endl;
            QThread::sleep(5);
            qDebugu()<<"QThread end"<<endl;
            this->exec();
        }
};
 
int main(int argc,char ** argv) //mian()作為主執行緒
{
    QApplication app(argc,argv);
 
    MyThread thread; //建立一個QThread派生類物件就是建立了一個子執行緒
    thread.start(); //啟動子執行緒,然後會自動呼叫執行緒函式run()
 
    qDebug()<<"main thread"<<QThread::currentThreadId()<<endl;
    QThread::sleep(5);
    qDebugu()<<"main thread"<<QThread::currentThreadId()<<endl;
 
    thread.quit(); //使用quit()或者exit()使得子執行緒能夠退出訊息迴圈,而不至於陷在子執行緒中
    thread.wait(); //等待子執行緒退出,然後回收資源
                   //thread.wait(5000); //設定等待的時間
    
    return app.exec();    
}

如果run()函式中沒有執行exec()訊息迴圈函式,那麼run()執行完了也就意味著子執行緒退出了。一般在子執行緒退出的時候需要主執行緒去回收資源,可以呼叫QThread的wait(),使主執行緒等待子執行緒退出,然後回收資源。這裡wait()是一個阻塞函式,有點像C++11中的join()函式。

但是!但是!但是!run()函式中呼叫了exec()函式,exec()是一個訊息迴圈,也可以叫做事件迴圈,也是會阻塞的,相當於一個死迴圈使子執行緒卡在這裡永不退出,必須呼叫QThread的quit()函式或者exit()函式才可以使子執行緒退出訊息迴圈,並且有時還不是馬上就退出,需要等到CPU的控制權交給執行緒的exec()。

所以先要thread.quit();使退出子執行緒的訊息迴圈, 然後thread.wait();在主執行緒中回收子執行緒的資源。

值得注意的有兩點:子執行緒的exet()訊息迴圈必須在run()函式中呼叫;如果沒有訊息迴圈的話,則沒有必要呼叫quit( )或者exit(),因為呼叫了也不會起作用。

第一種建立執行緒的方式需要在run()中顯式呼叫exec(),但是exec()有什麼作用呢,目前還看不出來,需要在第二種建立執行緒的方式中才能知道。


 ??2.2.使用訊號與槽方式來實現多執行緒

 剛講完使用QThread派生類物件的方法建立執行緒,現在就要來說它一點壞話。這種方法存在一個侷限性,只有一個run()函式能夠線上程中去執行,但是當有多個函式在同一個執行緒中執行時,就沒辦法了,至少實現起來很麻煩。所以,噹噹噹當,下面將介紹第二種建立執行緒的方式:使用訊號與槽的方式也就是把線上程中執行的函式(我們可以稱之為執行緒函式)定義為一個槽函式。

仍然是首先以文字形式說明這種方法的幾個步驟。

注意:必須通過發射訊號來讓槽函式在子執行緒中執行,發射的訊號存放在子執行緒訊息佇列中。要知道發射的訊號會經過一個包裝,記錄其傳送者和接收者等資訊,作業系統會根據該訊號的接收者將訊號放在對應執行緒的訊息佇列中。

  1. 繼承QObject來自定義一個類,該類中實現一個槽函式,也就是執行緒函式,實現執行緒要完成的工作;
  2. 在主執行緒(main函式)中例項化一個QThread物件,仍然用來管理子執行緒;
  3. 用繼承自QObject的自定義類來例項化一個物件,並通過moveToThread將自己放到執行緒QThread物件中;
  4. 使用connect()函式連結訊號與槽,因為一會兒執行緒啟動時會發射一個started()訊號;
  5. 呼叫QThread物件的start()函式啟動執行緒,此時會發出一個started()訊號,然後槽函式就會在子執行緒中執行了。

程式碼例項:

1️⃣在編輯好ui介面後,先建立一個workThread1的類。(繼承自QThread類),並定義槽函式(子執行緒執行的程式都可以放在槽函式中)

//workThread1.cpp(現在workThread1.h中宣告槽函式)

void
workThread1:: doWork() { qDebug()<<"當前執行緒ID:"<<QThread::currentThreadId(); qDebug()<<"開始執行"; QThread::sleep(10); qDebug()<<"結束執行"; }

2️⃣再主執行緒中(widget.cpp)例項化一個QThread物件thread。

 //需要引用#include<QThread>
QThread *thread=new QThread();

3️⃣在workThread1的類中例項化一個物件thread1,並通過moveToThread將自己放到執行緒QThread物件中

採用在widget.h中宣告,在widget中例項化(上面的例項化是直接例項化,這裡需要把thread1宣告在private中了)

  //widget.h中的private

workThread1 *thread1;
  //widget.cpp中

  thread1=new workThread1(this);//初始化
thread1->moveTOThread(thread);//將自定義的類的物件放入執行緒QThread物件中

4️⃣在按鈕的click事件中中列印主執行緒ID。

void Widget::on_pushButton_clicked()
{
    qDebug()<<"當前執行緒ID(主執行緒):"<<QThread::currentThreadId();     
}

5️⃣在widget.cpp中將按鈕事件(訊號)連線槽函式(即子執行緒),並執行執行緒thread。

在執行槽函式時,不能在此直接呼叫(如:thread1->doWork())。應該使用訊號與槽的方法(即用connect連線)

#include "widget.h"
#include "ui_widget.h"
#include<QThread>
#include<QDebug>

Widget::Widget(QWidget *parent)
    : QWidget(parent)
    , ui(new Ui::Widget)
{
    ui->setupUi(this);
//不能指定自定義類的物件的父類為widget,即沒有this(很重要!!!!) thread1
=new workThread1();//初始化 QThread *thread=new QThread(this); thread1->moveToThread(thread);
//執行緒結束時清理執行緒記憶體
connect(thread,&QThread::finished,thread,&QThread::deleteLater);
//將按鈕事件(訊號)繫結槽函式 connect(ui
->pushButton,&QPushButton::clicked,thread1,&workThread1::doWork);
//執行緒啟動 thread
->start(); } Widget::~Widget() { delete ui; } void Widget::on_pushButton_clicked() { qDebug()<<"當前執行緒ID(主執行緒):"<<QThread::currentThreadId(); }

也可以實現,在執行耗時操作時也可拖動介面。

 一般來說(這些程式都是要放在workThread1中的)

workThread1::workThread1(QObject *parent) : QObject(parent)
{
    QThread *thread=new QThread(this);
    moveToThread(thread);
    connect(thread,&QThread::finished,thread,&QThread::deleteLater);
    thread->start();
}

在主程式執行:

Widget::Widget(QWidget *parent)
    : QWidget(parent)
    , ui(new Ui::Widget)
{
    ui->setupUi(this);
    thread1=new workThread1();//初始化
    connect(ui->pushButton,&QPushButton::clicked,thread1,&workThread1::doWork);

}

特別需要注意的是(爬坑記錄):

一號坑:子執行緒中操作UI

Qt建立的子執行緒中是不能對UI物件進行任何操作的,即QWidget及其派生類物件,這個是我掉的第一個坑。可能是由於考慮到安全性的問題,所以Qt中子執行緒不能執行任何關於介面的處理,包括訊息框的彈出。正確的操作應該是通過訊號槽,將一些引數傳遞給主執行緒,讓主執行緒(也就是Controller)去處理。
 
二號坑:自定義的類不能指定父物件
比如上面程式中的:(不能指定自定義類物件為widget,即不可以加this)
thread1=new workThread1();//初始化

 三號坑:訊號的引數問題

 這個就實屬有毒,搞了我好久。這個涉及到了Qt的元物件系統(Meta-Object System)和訊號槽機制。
元物件系統即是提供了Qt類物件之間的訊號槽機制的系統。要使用訊號槽機制,類必須繼承自QObject類,並在私有宣告區域宣告Q_OBJECT巨集。當一個cpp檔案中的類宣告帶有這個巨集,就會有一個叫moc工具的玩意建立另一個以moc開頭的cpp原始檔(在debug目錄下),其中包含了為每一個類生成的元物件程式碼。

在使用connect函式的時候,我們一般會把最後一個引數忽略掉。這時候我們需要看下函式原型:

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

可以看到,最後一個引數代表的是連線的方式。

我們一般會用到方式是有三種

  • 自動連線(AutoConnection),預設的連線方式。如果訊號與槽,也就是傳送者與接受者在同一執行緒,等同於直接連線;如果傳送者與接受者處在不同執行緒,等同於佇列連線。

  • 直接連線(DirectConnection)。當訊號發射時,槽函式立即直接呼叫。無論槽函式所屬物件在哪個執行緒,槽函式總在傳送者所線上程執行。

  • 佇列連線(QueuedConnection)。當控制權回到接受者所線上程的事件迴圈時,槽函式被呼叫。這時候需要將訊號的引數塞到訊號佇列裡。槽函式在接受者所線上程執行。

所以線上程間進行訊號槽連線時,使用的是佇列連線方式。在專案中,我定義的訊號和槽的引數是這樣的:
signals:
    //自定義傳送的訊號
    void myThreadSignal(const int, string, string, string, string);

貌似沒什麼問題,然而實際執行起來槽函式根本就沒有被呼叫,程式沒有崩潰,VS也沒報錯。在查閱了N多部落格和資料中才發現,線上程間進行訊號槽連線時,引數不能隨便寫。

為什麼呢?我的後四個引數是標準庫中的string型別,這不是元物件系統內建的型別,也不是c++的基本型別,系統無法識別,然後就沒有進入訊號槽佇列中了,自然就會出現問題。解決方法有三種,最簡單的就是使用Qt的資料型別了

signals:
    //自定義傳送的訊號
    void myThreadSignal(const int, QString, QString, QString, QString);

第二種方法就是往元物件系統裡註冊這個型別。注意,在qRegisterMetaType函式被呼叫時,這個型別應該要確保已經被完好地定義了。

qRegisterMetaType<MyClass>("MyCl方法三是改變訊號槽的連線方式,將預設的佇列連線方式改為直接連線方式,這樣的話訊號的引數直接進入槽函式中被使用,槽函式立刻呼叫,不會進入訊號槽佇列中。但這種方式官方認為有風險,不建議使用。ss");

方法三是改變訊號槽的連線方式,將預設的佇列連線方式改為直接連線方式,這樣的話訊號的引數直接進入槽函式中被使用,槽函式立刻呼叫,不會進入訊號槽佇列中。但這種方式官方認為有風險,不建議使用。

connect(const QObject *sender, const char *signal, const QObject *receiver, const char *method, Qt::DirectConnection)

??總結一下:

  • 一定要用訊號槽機制,別想著直接呼叫,你會發現並沒有在子執行緒中執行。

  • 自定義的類不能指定父物件,因為moveToThread函式會將執行緒物件指定為自定義的類的父物件,當自定義的類物件已經有了父物件,就會報錯。

  • 當一個變數需要在多個執行緒間進行訪問時,最好加上voliate關鍵字,以免讀取到的是舊的值。當然,Qt中提供了執行緒同步的支援,比如互斥鎖之類的玩意,使用這些方式來訪問變數會更加安全。

 

相關文章