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

引言

前面幾篇已經對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中提供了執行緒同步的支援,比如互斥鎖之類的玩意,使用這些方式來訪問變數會更加安全。

 

Tags: