溫馨提示×

溫馨提示×

您好,登錄后才能下訂單哦!

密碼登錄×
登錄注冊×
其他方式登錄
點擊 登錄注冊 即表示同意《億速云用戶服務條款》

QT多線程深入分析

發布時間:2020-08-06 14:02:57 來源:網絡 閱讀:6137 作者:WZM3558862 欄目:開發技術

[譯] Threads, Events and QObjects

前言: qt wiki 中這篇文章3月份再次更新,文章對 QThread 的用法,使用場景,有很好的論述,可以作為 Qt 多線程編程的使用指南,原文在這里,原作者 peppe 開的討論貼在這里。

原文以姓名標識-相同方式分享 2.5 通用版發布

Creative Commons Attribution-ShareAlike 2.5 Generic

 

背景

在 #qt IRC channel [irc.freenode.net] 中,討論最多的話題之一就是多線程。很多同學選擇了多線程并行編程,然后……呃,掉進了并行編程的無盡的陷阱中。

由于缺乏 Qt 多線程編程經驗(尤其是結合Qt 信號槽機制的異步網絡編程)加上一些現有的其他語言(工具)的使用經驗,導致在使用 Qt 時,一些同學有朝自己腳開槍的行為QT多線程深入分析。Qt 的多線程支持是一把雙刃劍:雖然 Qt 的多線程支持使得多線程編程變得簡單,但同時也引入了一些其他特性(尤其是與 QObject 的交互),這些特性需要特別小心。

本文的目的不是教你如何使用多線程,加鎖、并行、擴展性,這不是本文的重點,而且這些問題已經有非常多的討論,可以參考這里[doc.qt.nokia.com] 的推薦。本文作為 Qt 多線程的指南,目的是幫助開發者避免常見的陷阱,開發出更健壯的程序。

知識背景

本文不是介紹多線程編程的文章,繼續閱讀下面的內容你需要以下的知識背景:

  • C++ 基礎 (強烈推薦,其他語言亦可)

  • Qt 基礎:QObject,信號槽,事件處理

  • 什么是線程,以及一個線程和其他線程、進程和操作系統之間的關系

  • 在主流的操作系統上,如何啟動和停止一個線程,如何等待線程結束

  • 如何使用互斥量(mutex),信號量(semaphore),條件等待(wait condition)創建線程安全/可重入的函數,結構和類。

本文中使用 Qt 的名詞定義 [doc.qt.nokia.com]

  • 可重入 如果多個線程同時訪問某個類的(多個)對象且一個對象同時只有一個線程訪問,是安全的,那么這個類是可重入的。如果多個線程同時調用一個函數且只訪問該線程可見的數據,是安全的,那么這個函數是可重入的。換句話說,訪問這些對象/共享數據時,必須通過外部加鎖機制來實現串行訪問,保證安全。

  • 線程安全 如果多個線程同時訪問某個類的對象是安全的,那么這個類是線程安全的。如果多個線程同時調用一個函數(即使訪問了共享數據)是安全的,那么這個函數時線程安全的。

 

事件和事件循環

作為一個事件驅動的系統,事件和事件分發在 Qt 的架構中扮演著核心角色。本文不會全面覆蓋這個主題;我們主要闡述和線程相關的一些概念(有關 Qt 事件系統的文章,請看這里,還有這里)。

在 Qt 中,一個事件是一個對象,它表示一些有趣的事情發生了;信號和事件的主要區別在于,在我們的程序中事件的目標是確定的對象(這個對象決定如何處理該事件),但信號可以發到“任何地方”。從代碼級別來講,所有的事件對象都是 QEvent  [doc.qt.nokia.com] 的子類,所有繼承自 QObject 的類都可以重寫 QObject::event() 虛函數,來作為事件的目標處理者。

事件即可以來自應用程序內部,也可以來自外部;例如:

  • QKeyEvent 和 QMouseEvent 對象代表鼠標、鍵盤的交互,這些事件來自于窗口管理器。

  • QTimerEvent 對象會在計時器超時的時候,發送給另一個 QObject,這些事件(通常)來自于操作系統。

  • QChildEvent 對象會在添加或刪除一個child時,發送給另一個 QObject,這些事件來自于你的程序中。

關于事件,有一個很重要的事情,那就是事件不會一產生就發送給需要處理這個事件的對象;而是放到事件隊列中,然后再發送。事件分發器會循環處理事件隊列,把每個在隊列中的事件分發給相應的對象,因此這個又叫做事件循環。從概念上講,事件循環看起來是這樣的:

while (is_active)
{
    while (!event_queue_is_empty)
        dispatch_next_event();
 
    wait_for_more_events();
}

在 Qt 的使用中,通過調用 QCoreApplication::exec() 進入 Qt 的主消息循環;這個函數會阻塞,直到調用 QCoreApplication::exit() 或 QCoreApplication::quit(),結束消息循環。

函數 "wait_for_more_events()" 會阻塞(不是忙等)直到有事件產生。稍加考慮,我們就會發現,在這時事件一定是從外部產生的(事件分發器已經結束并且也沒有新的事件在事件隊列中等待分發)。因此,事件循環可以在以下幾種情況下被喚醒:

  • 窗口管理器(鍵盤/鼠標點擊,和窗口的交互,等)

  • 套接字(sockets)(數據可讀、可寫、有新連接,等)

  • 計時器(計時器超時)

  • 從其他線程發送來的事件(稍后討論)

在 Unix-like 系統中,窗口管理器的活動(例如 X11)是通過套接字(socket)(Unix Domain or TCP/IP)通知給應用程序的,因為客戶端是通過套接字和 X Server 通信的。如果我們使用內部的 socketpair(2) 來實現跨線程的消息發送,那么我們要做的就是通過某些活動喚醒消息循環:

  • 套接字(socket)

  • 計時器

系統調用 select(2) 是這么工作的:它監聽著一個活動描述符的集合,如果一段時間(可配置超時事件)內都沒有活動那么它就會超時。Qt 所需要做的就是把 select 返回的結果轉化為一個 QEvent 對象(子類對象)然后把它放入事件隊列中?,F在你應該知道消息循環內部事怎么回事兒了QT多線程深入分析。

哪些東西需要事件循環?

下面不是完整的列表,不過稍微思考一下,你就能猜出那些類需要消息循環了。

  • Widget 繪圖(painting)和交互:當接收到 QPaintEvent 對象時,函數 QWidget::paintEvent() 會被調用,QPaintEvent 對象的產生,有可能是調用 QWidget::update() (應用程序內部調用) 函數,或者來自窗口管理器(例如:把一個隱藏的窗口顯示出來)。其他類型的交互(鼠標、鍵盤,等)也是一樣的:這些事件都需要一個事件循環來分發事件。

  • 計時器:簡單說,當 select(2) 或類似的調用超時的時候,計時器超時事件被觸發,因此你需要消息循換來處理這些調用。

  • 網絡通信:所有 low-level 的 Qt 網絡通信類(QTcpSocket, QUdpSocket, QTcpServer,等)都設計為異步的。當調用 read() 函數時,它們僅僅返回當前可用的數據,當調用 write() 函數時,它們會安排稍后再寫。僅僅當程序返回消息循換的時候,讀/寫操作才真正發生。注意雖然提供有同步的方法(那些以 waitFor* 命名的函數),但是它們并不好用,因為在等待的同時他們阻塞了消息循換。像 QNetworkAccessManager 這樣的 high-level 類,同樣需要消息循換,但不提供任何同步調用的接口。

阻塞消息循換

在討論為什么我們不應該阻塞消息循換之前,先說明一下“阻塞”的含義是什么。想像一下,有一個在點擊時可以發送信號的按鈕,信號綁定到我們的工作類對象的一個槽函數上,這個槽函數會做很多工作。當你點擊按鈕時,函數調用??雌饋響撓裣旅孢@樣(棧底在上):

main(int, char **)
QApplication::exec()
[…]
QWidget::event(QEvent *)
Button::mousePressEvent(QMouseEvent *)
Button::clicked()
[…]
Worker::doWork()

在 main() 函數中,我們通過調用 QApplication::exec() (第2行) 啟動了一個消息循換。窗口管理器發送一個鼠標點擊的事件,Qt 內核會得到這個消息,然后轉化為一個 QMouseEvent 對象,通過 QApplication::notify()(此處沒有列出)函數發送給 widget 的 event() 函數(第4行)。如果按鈕沒有重寫 event() 函數,那么他的基類(QWidget)實現的 event() 函數會被調用。QWidget::event() 檢測到鼠標點擊事件,然后調用相應的事件處理函數,就是上面代碼中的 Button::mousePressEvent()(第5行)函數。我們重寫了這個函數,讓他發送一個 Button::clicked() 信號(第6行),這個信號會調用 Worker 類對象的槽函數 Worker::doWork() (第8行)。

當 Worker 對象正在忙于工作的時候,消息循換在做什么?我們可能會猜測:什么也不做!消息循換分發了鼠標點擊事件然后等待,等待消息處理者返回。我們阻塞了消息循換,這意味在槽函數 doWork() 返回之前,不會再有消息被分發出去,消息會不斷進入消息隊列而不能的得到及時的處理。

當事件分發被卡住的時候,窗口不會刷新(QPaintEvent 對象在消息隊列中),不能響應其他的交互行為(和前面的原因一樣),定時器超時事件不會觸發、網絡通信變慢然后停止。此外,很多窗口管理器會檢測到你的程序不再處理事件,而提示程序無響應。這就是為什么迅速的處理事件然后返回消息循環如此重要的原因。

強制分發事件

那么,如果有一個耗時的任務同時我們又不想阻塞消息循換,這時該如何去做?一個可能的回答是:把這個耗時的任務移動到其他的線程中:下一節中我們可以看到如何做。我們還有一個可選的辦法,那就是在我們耗時的任務中通過調用 QCoreApplication::processEvents() 來手動強制跑起消息循換。QCoreApplication::processEvents() 會處理所有隊列上的事件然后返回。

另一個可選的方案,我們可以利用 QEventLoop [doc.qt.nokia.com] 強制再加入一個消息循環。通過調用 QEventLoop::exec() 函數,我們加入一個消息循換,然后連接一個信號到  QEventLoop::quit() 槽函數上,來讓循環退出。例如:

QNetworkAccessManager qnam;
QNetworkReply *reply = qnam.get(QNetworkRequest(QUrl(...)));
QEventLoop loop;
QObject::connect(reply, SIGNAL(finished()), &loop, SLOT(quit()));
loop.exec();
/* reply has finished, use it */

QNetworkReply 不提供阻塞的接口,同時需要一個消息循環。我們進入了一個局部的 QEventLoop,當 reply 發出 finished 信號時,這個事件循環就結束了。

通過“其他路徑”重入消息循換時需要特別小心:這可能導致不期望的遞歸!回到剛才的按鈕例子中。如果我們再槽函數 doWork() 中調用 QCoreApplication::processEvents() ,同時用戶再次點擊了按鈕,這個槽函數 doWork() 會再一次被調用:

main(int, char **)
QApplication::exec()
[…]
QWidget::event(QEvent *)
Button::mousePressEvent(QMouseEvent *)
Button::clicked()
[…]
Worker::doWork() // first, inner invocation
QCoreApplication::processEvents() // we manually dispatch events and…
[…]
QWidget::event(QEvent * ) // another mouse click is sent to the Button…
Button::mousePressEvent(QMouseEvent *)
Button::clicked() // which emits clicked() again…
[…]
Worker::doWork() // DANG! we’ve recursed into our slot.

一個快速簡單的規避辦法是給 QCoreApplication::processEvents() 傳入一個參數 QEventLoop::ExcludeUserInputEvents,它會告訴消息循換不要分發任何用戶輸入的事件(這些事件會停留在隊列中)。

幸運的是,同樣的問題不會出現在刪除事件中(調用 QObject::deleteLater() 會發送該事件到事件隊列中)。事實上,Qt 使用了特別的辦法來處理它,當消息循環比 deleteLater 調用發生的消息循環更外層時,刪除事件才會被處理。例如:

QObject *object = new QObject;
object->deleteLater();
QDialog dialog;
dialog.exec();

這不會導致 object 空懸指針(QDialog::exec() 中的消息循環,比 deleteLater 調用發生的地方層次更深)。同樣的事情也會發生在 QEventLoop 啟動的消息循環中。我只發現過一個例外(在 Qt 4.7.3 中),如果在沒有任何消息循環的時候調用了 deleteLater,那么第一個啟動的消息循環會處理這個消息,刪除該對象。這是很合理的,因為 Qt 知道不會有任何會執行刪除動作的“外層”循環,因此會立即刪除該對象。

 

Qt 線程類

Qt 支持多線程已經很多年(2000 年9月22日發布的 Qt 2.2 引入了 QThread 類),4.0 版本在所有平臺上都默認開啟多線程支持(多線程支持是可以關閉的,更多細節看這里[doc.qt.nokia.com])。Qt 現在提供了很多類來實現多線程;下面就來看一下。

QThread

QThread [doc.qt.nokia.com] 是 Qt 中多線程支持的核心的 low-level 類。一個 QThread 對象表示一個執行的線程。由于 Qt 的跨平臺特性,QThread 設法隱藏了不同操作系統在線程操作中的所有平臺相關的代碼。

為了使用 Qthread 在一個線程中執行代碼,我們繼承 QThread 然后重寫 QThread::run() 函數:

class Thread : public QThread {
protected:
    void run() {
        /* your thread implementation goes here */
    }
};

然后這么使用

Thread *t = new Thread;
t->start(); // start(), not run()!

來啟動一個新的線程。注意,從 Qt 4.4 開始,QThread 不再是抽象類,現在虛函數 QThread::run() 有了調用 QThread::exec() 的默認實現;它會啟動線程自己的消息循環(稍后詳細說明)。

QRunnable 和 QThreadPool

QRunnable [doc.qt.nokia.com] 是一個輕量級的抽象類,它可以在另一個線程中啟動一個任務,適用于“運行完就丟掉”這種情況。實現這個功能,我們需要做的就是繼承 QRunnable 然后實現純虛函數 run():

class Task : public QRunnable {
public:
    void run() {
        /* your runnable implementation goes here */
    }
};

我們使用 QThreadPool [doc.qt.nokia.com] 類,它管理著一個線程池,來真正運行一個 QRunnable 對象。當調用 QThreadPool::start(runnable) 時,我們將 QRunnable 對象放入 QThreadPool 的執行隊列中;當線程可用時,QRunnable 對像會啟動,然后在線程中執行。所有的 Qt 應用程序都有一個全局的線程池,可以通過調用  QThreadPool::globalInstance() 來獲得,但是也可以創建一個私有的 QThreadPool 對象來顯式的管理。

注意,QRunnable 不是一個 QObject,因此沒有QObject內建的和其他一些組建通信的機制;你不得不使用 low-level 線程原語手工處理(例如用互斥量保護隊列來收集結果等)。

QtConcurrent

QtConcurrent [doc.qt.nokia.com] 是 high-level API,在 QThreadPool 基礎上構建而成,它可以應用在大部分常用的并行計算范式中:map [en.wikipedia.org]), reduce [en.wikipedia.org]), 和 filter [en.wikipedia.org]);它同時提供 QtConcurrent::run() 方法,可以簡單的在另一個線程中啟動一個函數。

與 QThread 和 QRunnable 不同,QtConcurrent 不需要我們使用 low-level 的同步原語:所有 QtConcurrent 函數返回一個QFuture [doc.qt.nokia.com] 對象,它可以用來查詢計算狀態(進展),暫停/恢復/取消計算,同時它也包含計算的結果。QFutureWatcher [doc.qt.nokia.com] 類可以用來監測 QFuture 的進展,也可以通過信號槽來和 QFuture 交互(注意,QFuture 作為一個值語義的類,沒有繼承自 QObject)。

特性對比

\QThreadQRunnableQtConcurrent1
high level 接口nny
面向任務nyy
內建支持暫停/恢復/取消nny
支持優先級ynn
可以運行消息循環ynn




1 QtConcurrent::run 是個例外,因為它是使用 QRunnable 實現的,所以帶有 QRunnable 的特性。

 

線程和QObject

每個線程一個消息循環

到現在為止,我們已經討論過“消息循環”,但討論的僅僅是在一個 Qt 應用程序中只有一個消息循換的情況。但不是下面這種情況:QThread 對象可以啟動一個自己代表的線程中的消息循換。因此,我們把在 main() 函數中通過調用 QCoreApplication::exec()(該函數只能在主線程中調用)啟動的消息循換叫做主消息循環。它也叫做 GUI 線程,因為 UI 相關的操作只能(應該)在該線程中執行。一個 QThread 局部消息循換可以通過調用 QThread::exec() 來啟動(在 run() 函數中):

class Thread : public QThread {
protected:
    void run() {
        /* ... initialize ... */
 
        exec();
    }
};

上面我們提到,從 Qt 4.4 開始,QThread::run() 不再是一個純虛函數,而是默認調用 QThread::exec()。和 QCoreApplication 一樣,QThread 也有 QThread::quit() 和 QThread::exit() 函數,來停止消息循換。

一個線程的消息循環為所有在這個線程中的 QObject 對象分發消息;默認的,它包括所有在這個線程中創建的對象,或者從其他線程中移過來的對象(接下來詳細說明)。同時,一個 QObject 對象的線程相關性是確定的,也就是說這個對象生存在這個線程中。這個適用于在 QThread 對象的構造函數中創建的對象:

class MyThread : public QThread
{
public:
    MyThread()
    {
        otherObj = new QObject;
    }    
 
private:
    QObject obj;
    QObject *otherObj;
    QScopedPointer<QObject> yetAnotherObj;
};

在創建一個 MyThread 對象之后,obj,otherObj,yetAnotherObj 的線程相關性如何?我們必須看看創建這些對象的線程:它是運行 MyThread 構造函數的線程。因此,所有這三個對象都不屬于 MyThread 線程,而是創建了 MyThread 對象的線程(MyThread 對象也屬于該線程)。

QT多線程深入分析

我們可以使用線程安全的 QCoreApplication::postEvent() 函數來給對象發送事件。它會把事件放入該對象所在消息循環的事件隊列中;因此,只有這個線程有消息循環,消息才會被分發。

理解 QObject 和它的子類不是線程安全的(雖然它是可重入的)這非常重要;由于它不是線程安全的,所以你不能同時在多個線程中同時訪問同一個 QObject 對象,除非你自己串行化了所有對這些內部數據的訪問(比如使用了互斥量來保護內部數據)。記住當你從其他線程訪問 QObject 對象時,這個對象有可能正在處理它所在的消息循環分發給它的事件。同樣的,你也不能從另一個線程中刪除一個 QObject 對象,而必須使用 QObject::deleteLater() 函數,它會發送一個事件到對象所在線程中,然后在該線程中刪除對象。

此外,QWidget 和它的所有子類,還有其他的 UI 相關類(非 QObject 子類,比如 QPixmap)還是不可重入的:他們僅僅可以在 UI 線程中使用。

我們可以通過調用 QObject::moveToThread() 來改變 QObject 對象和線程之前的關系,它會改變對象本身以及它的孩子與線程之前的關系。由于 QObject 不是線程安全的,所以我們必須在它所在的線程中使用;也就是說,你僅僅可以在他們所處的線程中把它移動到另一個線程,而不能從其他線程中把它從所在的線程中移動過。而且,Qt 要求一個 QObject 對象的漢子必須和他的父親在同一個線程中,也就是說:

  • 如果一個對象有父親,那么你不能使用 QObject::moveToThread() 把它移動到其他線程

  • 你不能在 QThread 類中以 QThread 為父親創建對象

class Thread : public QThread {
    void run() {
        QObject *obj = new QObject(this); // WRONG!!!
    }
};

這是因為 QThread 對象所在的線程是另外的線程,即,QThread 對象所在的線程是創建它的線程。

Qt 要求所有在線程中的對象必須在線程結束之前銷毀;利用 QThread::run() 函數,在該函數中僅創建棧上的對象,這一點可以很容易的做到。

跨線程信號槽

有了這些前提,我們如何調用另一個線程中 QObject 對象的函數?Qt 提供了一個非常漂亮和干凈的解決方案:我們發送一個事件到線程的消息隊列中,事件的處理,將調用我們感興趣的函數(當然這個線程需要啟動一個事件循環)。該設施圍繞 Qt 的元對象編譯器(MOC)提供的方法內省而構建:因此,信號,槽,函數,只要使用了 Q_INVOKABLE 宏,那么就可以從另外的線程調用它。

QMetaObject::invokeMethod() 靜態方法為我們實現了這個功能:

QMetaObject::invokeMethod(object, "methodName",
                          Qt::QueuedConnection,
                          Q_ARG(type1, arg1),
                          Q_ARG(type2, arg2));

注意,由于參數需要在消息傳遞時拷貝,這些類型的參數需要提供公有的構造函數,析構函數和拷貝構造函數,而且要使用 qRegisterMetaType() 函數將類型注冊到 Qt 類型系統中。

跨線程的信號槽工作方式是類似的。當我們將信號和曹連接時,QObject::connect 函數的第5個參數可以指定連接的類型:

  • direct connection:意思是槽函數會在信號發送的線程中直接被調用。

  • queued connection:意思是事件會發送到接收者所在線程的消息隊列中,消息循環會稍后處理該事件然后調用槽函數。

  • blocking queued connection:和 queued connection 類似,但是發送線程會阻塞,直到接收者所在線程的消息循環處理了該事件,調用了槽函數之后,才會返回;

在任何情況下,記住發送者所在的線程一點都不重要!在自動連接的情況下,Qt 會檢查信號調用的線程,然后與接收者所在線程比較,然后決定使用哪種連接類型。特別的,Threads and QObjects [doc.qt.nokia.com] (4.7.1) 在下面的情況下是錯誤的

自動連接(默認值),如果發送者和接收者在同一線程它和直接連接(direct connection)的行為是一樣的;如果發送者和接收者在不同的線程它和隊列連接(queued connection)的行為是一樣的。

因為發送者所在的線程和無關緊要的。例如:

class Thread : public QThread
{
    Q_OBJECT
 
signals:
    void aSignal();
 
protected:
    void run() {
        emit aSignal();
    }
};
 
/* ... */
Thread thread;
Object obj;
QObject::connect(&thread, SIGNAL(aSignal()), &obj, SLOT(aSlot()));
thread.start();

信號 aSignal() 會在一個新的線程中發送(Thread 對象創建的線程);因為這不是 Object  對象所在的線程(但這時,Object 對象與 Thread 對象在同一個線程中,再次強調,發送者所在線程是無關緊要的),這時將使用 queued connection。

另一個常見的陷阱:

class Thread : public QThread
{
    Q_OBJECT
 
slots:
    void aSlot() {
        /* ... */
    }
 
protected:
    void run() {
        /* ... */
    }
};
 
/* ... */
Thread thread;
Object obj;
QObject::connect(&obj, SIGNAL(aSignal()), &thread, SLOT(aSlot()));
thread.start();
obj.emitSignal();

當“obj” 發送 aSignal() 信號時,將會使用哪種連接類型?你應該已經猜到了:direct connection。這是因為 Thread 對象所在線程就是信號發送的線程。在槽函數 aSlot() 中,我們可能訪問 Thread 類的成員,而同時 run() 函數可能也在訪問,他們會同時進行:這是完美的災難配方。

另一個例子,或許也是最重要的一個:

class Thread : public QThread
{
    Q_OBJECT
 
slots:
    void aSlot() {
        /* ... */
    }
 
protected:
    void run() {
        QObject *obj = new Object;
        connect(obj, SIGNAL(aSignal()), this, SLOT(aSlot()));
        /* ... */
    }
};

在上面的情形中,連接類型是 queued connection,因此你需要在 Thread 對象所在線程啟動一個消息循環。

下面是一個你經??梢栽谡搲?、博客或其他地方看到的解決方案。那就是在 Thread 的構造函數中增加一個 moveToThread(this) 函數:

class Thread : public QThread {
    Q_OBJECT
public:
    Thread() {
        moveToThread(this); // WRONG
    }
 
    /* ... */
};

這確實可以工作(因為現在線程對象所在的線程的確改變了),但是這是個非常糟糕的設計。錯誤在于我們誤解了 thread 對象(QThread 子類)的目的:QThread 對象不是線程本身;它是用于管理線程的,因此它應該在另一個線程中使用(通常就是創建它的線程)。

一個好的辦法是:把“工作”部分從“控制”部分分離出來,創建 QObject 子類對象,然后使用 QObject::moveToThread() 來改變對象所在的線程:

class Worker : public QObject
{
    Q_OBJECT
 
public slots:
    void doWork() {
        /* ... */
    }
};
 
/* ... */
QThread *thread = new QThread;
Worker *worker = new Worker;
connect(obj, SIGNAL(workReady()), worker, SLOT(doWork()));
worker->moveToThread(thread);
thread->start();

應該做&不應該做

你可以…

  • 在 QThread 子類中添加信號。這是很安全的,而且可以“正確工作”(前面提到;發送者所在線程是無關緊要的)。

你不應該…

  • 使用 moveToThread(this)

  • 強制連接類型:這通常說明你在做一些錯誤的事情,例如混合了 QThread 控制接口和程序邏輯(它應該在該線程創建的對象中)

  • 在 QThread 子類中增加槽函數:它們會在“錯誤的”線程中被調用,不是在 QThread 管理的線程中,而是在 QThread 對象創建的線程,迫使你使用 direct connection 或使用 moveToThread(this) 函數。

  • 使用 QThread::terminate 函數。

禁止…

  • 在線程還在運行時退出程序。使用 QThread::wait 等待線程終止。

  • 當 QThread 管理的線程還在運行時,刪除 QThread 對象。如果你想要“自動析構”,你可以將 finished() 信號連接到 deleteLater() 槽函數上。

 

什么時候應該使用線程?

當使用阻塞 API 時

如果你需要使用沒有提供非阻塞API的庫(例如信號槽,事件,回調函數,等),那么避免阻塞消息循環的唯一解決方案就是開啟一個進程或線程。由于創建一個工作進程,讓它完成任務并通過進程通信返回結果與開啟一個線程相比是困難并且昂貴的,所以創建一個線程是更普遍的做法。

地址解析(只是舉個例子,不是在討論蹩腳的第三方 API。這是每一個 C 語言函數庫中包含的東西)就是一個很好的例子,它把主機名轉換為地址。它會調用域名解析系統DNS)來查詢。雖然一般情況下,它會立即返回,但是遠程服務器有可能故障,有可能丟包,有可能網絡突然中斷,等等。簡而言之,它可能需要等待很長時間才相應我們發出的請求。

UNIX 系統中的標準 API 是阻塞的(不僅僅是舊的 API gethostbyname(3),新的更好的 getservbyname(3) 和 getaddrinfo(3) 也是一樣)。QHostInfo [doc.qt.nokia.com] 是處理主機名解析的 Qt 類,它使用 QThreadPool 來使得請求在后臺運行(看這里 [qt.gitorious.com];如果線程支持被關閉的話,它會切換為阻塞方式)。

另一個簡單的例子是圖像加載和縮放。QImageReader [doc.qt.nokia.com] 和 QImage [doc.qt.nokia.com] 只提供阻塞方法來從設備讀取圖像,或改變圖像的分辨率。如果你正在處理非常大的圖像,這些操作可能會花費數十秒。

當你想要充分利用多CPU時

多線程可以讓你的程序更好的利用多處理器系統。每個線程是由操作系統獨立調用的,如果你的程序運行在這樣的機器上,線程調度就可以讓多個處理器同時運行不同的線程。

比如,考慮一個批量生成縮略圖的程序。一個有 n 個線程的線程農場(有固定線程數目的線程池),n 是系統中可用 CPU 的數量(可參考 QThread::idealThreadCount()),它可以將處理任務分布到多個cpu上,這樣我們就可以獲得與cpu數量有關的效率線性增長(簡單的,我們把CPU考慮為瓶頸)。

當你不想被阻塞時

呃…從一個例子開始會更好。

這是一個高級話題,你可以暫時忽略。Webkit 中的 QNetworkAccessManager 是一個很好的例子。Webkit 是一個流行的瀏覽器引擎,它是處理網頁布局和顯式的一組類的集合,Qt 中 QwebView 類使用了它。

QNetworkAccessManager 是 Qt 中處理 HTTP 請求和響應的類,我們可以把它當作瀏覽器的引擎。Qt 4.8 之前,它沒有使用任何工作線程;所有的處理都在 QNetworkAccessManager 和 QNetworkReply 所在的同一個線程。

雖然在網絡通信中使用線程是一個好辦法,但是它也存在問題:如果你沒有盡快從 socket 中讀取數據,內核緩沖會被其他數據填充,數據包將被丟掉,可想而知,數據傳輸速率將下降。

socket 活動(也就是 socket 是否可讀)是由 Qt 的事件循環還管理的。阻塞事件循環會導致傳輸性能下降,因為這時沒有人會被告知現在數據已經可讀(所以沒有人會去讀取數據)。

但是什么會阻塞消息循環?可悲的是:WebKit 自己阻塞了消息循環。一旦消息可讀,Webkit 開始處理網頁布局。不幸的是,這個處理是復雜而昂貴的,它會阻塞消息循換一(?。?,但足以影響傳輸效率(寬帶連接這里起到了作用,在短短幾秒內就可填滿內核緩存)。

總結一下,這個過程發生的事情:

  • Webkit 發起請求;

  • 一些響應數據開始到達;

  • Webkit 開始使用到達的數據來網頁布局,阻塞了事件循環;

  • 沒有了事件循環,操作系統接收到了數據,但沒有人從 QNetworkAccessManager 的 socket 中讀取數據;

  • 內核緩沖將被其他數據填充,從而導致傳輸效率下降。

整個頁面的加載時間由于 Webkit 自己引起的問題而變得很慢。

注意,由于 QNetworkAccessManager 和 QNetworkReply 都是 QObject,它們都不是線程安全的,因此你不能將它移動到另一個線程然后繼續在你的線程中繼續使用它,因為你可能從兩個線程中同時訪問它:你自己的線程和它所在的線程,因為它所在的消息循環會將事件分發給它處理。

在 Qt 4.8 中,QNetworkAccessManager 現在默認使用單獨的線程處理 HTTP 請求,因此 UI 反應慢和系統緩沖被填充過快的問題得以解決。

 

什么時候不應該使用線程?

計時器

這可能是最糟糕的線程濫用。如果你不得不重復調用一個方法(例如,每秒調用一次),很多人會這么做:

// VERY WRONG
while (condition) {
    doWork();
    sleep(1); // this is sleep(3) from the C library
}

然后會發現這阻塞了事件循環,然后決定使用線程來解決:

// WRONG
class Thread : public QThread {
protected:
    void run() {
        while (condition) {
            // notice that "condition" may also need volatiness and mutex protection
            // if we modify it from other threads (!)
            doWork();
            sleep(1); // this is QThread::sleep()
        }
    }
};

一個更好更簡單的辦法是使用計時器,一個超時時間為1秒的 QTimer [doc.qt.nokia.com] 對象,和 doWork() 槽函數:

class Worker : public QObject
{
    Q_OBJECT
 
public:
    Worker() {
        connect(&timer, SIGNAL(timeout()), this, SLOT(doWork()));
        timer.start(1000);
    }
 
private slots:
    void doWork() {
        /* ... */
    }
 
private:
    QTimer timer;
};

我們所需要做的就是啟動一個消息循環,然后 doWork() 函數會每一秒調用一次。

網絡通信/狀態機

下面是一個非常常見的網絡通信的設計:

socket->connect(host);
socket->waitForConnected();
 
data = getData();
socket->write(data);
socket->waitForBytesWritten();
 
socket->waitForReadyRead();
socket->read(response);
 
reply = process(response);
 
socket->write(reply);
socket->waitForBytesWritten();
/* ... and so on ... */

不用多說,這些 waitFor*() 函數調用會阻塞消息循環,凍結 UI,等等。注意,上面的代碼沒有任何的錯誤處理,不然它會更繁瑣。上面的錯誤在于我們忘記了最初網絡設計的就是異步的,如果我們使用同步處理,那就是朝自己的腳開槍。解決上面的問題,許多人會簡單的把它移動到不同的線程中。

另一個更抽象的例子:

result = process_one_thing();
 
if (result->something())
    process_this();
else
    process_that();
 
wait_for_user_input();
input = read_user_input();
process_user_input(input);
/* ... */

它和上面網絡的例子有著同樣的陷阱。

讓我們退一步,從更高的視角來看看我們構建的東西,我們構建了一個狀態機來處理輸入。

  • 空閑 –> 連接中(調用 connectToHost())

  • 連接中 –> 已連接 (發出 connected() 信號)

  • 已連接 –> 發送登陸數據(發送登陸數據到服務器)

  • 發送登陸數據 –> 登陸成功(服務器返回 ACK)

  • 發送登陸數據 –> 登陸失?。ǚ掌鞣祷?NACK)

等等。

現在,我們有很多辦法來構建一個狀態機(Qt 就為我們提供了一個可使用的類:QStateMachine [doc.qt.nokia.com]),最簡單的辦法就是使用枚舉(整型)來記錄當前的狀態。我們可以重寫上面的代碼:

class Object : public QObject
{
    Q_OBJECT
 
    enum State {
        State1, State2, State3 /* and so on */
    };
 
    State state;
 
public:
    Object() : state(State1)
    {
        connect(source, SIGNAL(ready()), this, SLOT(doWork()));
    }
 
private slots:
    void doWork() {
        switch (state) {
            case State1:
                /* ... */
                state = State2;
                break;
            case State2:
                /* ... */
                state = State3;
                break;
            /* etc. */
        }
    }
};

“source” 對象和“ready()”信號是什么?我們想要的是:拿網絡例子來說,我們想要把 QAbstractSocket::connected() 和 QIODevice::readyRead() 連接到我們的槽函數上。當然,如果再多些槽函數更好的話,我們也可以增加更多(比如錯誤處理的槽函數,由 QAbstractSocket::error() 信號來發起)。這是真正的異步,信號驅動的設計!

把任務分解成小塊

想想一下我們有個很耗時但是無法移動到其它線程的任務(或者根本不能移動到其它線程,因為它可能必須在 UI 線程中執行)。如果我們把任務分解成小塊,那么我們就可以返回消息循環,讓消息循環分發事件,然后讓它調用處理后續任務塊的函數。如果我們還記得 queued connection 如何實現的話,那就很容易解決這個問題了:事件發送到接收者所在的事件循環中,當事件被分發的時候,相應的槽函數被調用。

我們可以使用 QMetaObject::invokeMethod() 函數,用參數 Qt::QueuedConnection 指定連接類型,來實現這個功能;這需要函數可調用,也就是說函數必須是個槽函數或者使用了 Q_INVOKABLE 宏修飾。如果我們還要給函數傳遞參數,那么我們要保證參數類型已經通過函數 qRegisterMetaType() 注冊到了 Qt 的類型系統中。下面的代碼給我們展示了這種做法:

class Worker : public QObject
{
    Q_OBJECT
public slots:
    void startProcessing()
    {
        processItem(0);
    }
 
    void processItem(int index)
    {
        /* process items[index] ... */
 
        if (index < numberOfItems)
            QMetaObject::invokeMethod(this,
                                     "processItem",
                                     Qt::QueuedConnection,
                                     Q_ARG(int, index + 1));
 
    }
};

因為這里沒有線程調用,所以它可以很容易的暫停/恢復/取消任務,也可以很容易的得到計算結果。

 

一些例子

MD5 hash

 

參考

  • Bradley T. Hughes: You’re doing it wrong… [labs.qt.nokia.com], Qt Labs blogs, 2010-06-17

  • Bradley T. Hughes: Threading without the headache [labs.qt.nokia.com], Qt Labs blogs, 2006-12-04


向AI問一下細節

免責聲明:本站發布的內容(圖片、視頻和文字)以原創、轉載和分享為主,文章觀點不代表本網站立場,如果涉及侵權請聯系站長郵箱:is@yisu.com進行舉報,并提供相關證據,一經查實,將立刻刪除涉嫌侵權內容。

qt
AI

亚洲午夜精品一区二区_中文无码日韩欧免_久久香蕉精品视频_欧美主播一区二区三区美女