溫馨提示×

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

密碼登錄×
登錄注冊(cè)×
其他方式登錄
點(diǎn)擊 登錄注冊(cè) 即表示同意《億速云用戶服務(wù)條款》

QT多線程深入分析

發(fā)布時(shí)間:2020-08-06 14:02:57 來源:網(wǎng)絡(luò) 閱讀:6071 作者:WZM3558862 欄目:開發(fā)技術(shù)

[譯] Threads, Events and QObjects

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

原文以姓名標(biāo)識(shí)-相同方式分享 2.5 通用版發(fā)布

Creative Commons Attribution-ShareAlike 2.5 Generic

 

背景

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

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

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

知識(shí)背景

本文不是介紹多線程編程的文章,繼續(xù)閱讀下面的內(nèi)容你需要以下的知識(shí)背景:

  • C++ 基礎(chǔ) (強(qiáng)烈推薦,其他語言亦可)

  • Qt 基礎(chǔ):QObject,信號(hào)槽,事件處理

  • 什么是線程,以及一個(gè)線程和其他線程、進(jìn)程和操作系統(tǒng)之間的關(guān)系

  • 在主流的操作系統(tǒng)上,如何啟動(dòng)和停止一個(gè)線程,如何等待線程結(jié)束

  • 如何使用互斥量(mutex),信號(hào)量(semaphore),條件等待(wait condition)創(chuàng)建線程安全/可重入的函數(shù),結(jié)構(gòu)和類。

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

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

  • 線程安全 如果多個(gè)線程同時(shí)訪問某個(gè)類的對(duì)象是安全的,那么這個(gè)類是線程安全的。如果多個(gè)線程同時(shí)調(diào)用一個(gè)函數(shù)(即使訪問了共享數(shù)據(jù))是安全的,那么這個(gè)函數(shù)時(shí)線程安全的。

 

事件和事件循環(huán)

作為一個(gè)事件驅(qū)動(dòng)的系統(tǒng),事件和事件分發(fā)在 Qt 的架構(gòu)中扮演著核心角色。本文不會(huì)全面覆蓋這個(gè)主題;我們主要闡述和線程相關(guān)的一些概念(有關(guān) Qt 事件系統(tǒng)的文章,請(qǐng)看這里,還有這里)。

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

事件即可以來自應(yīng)用程序內(nèi)部,也可以來自外部;例如:

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

  • QTimerEvent 對(duì)象會(huì)在計(jì)時(shí)器超時(shí)的時(shí)候,發(fā)送給另一個(gè) QObject,這些事件(通常)來自于操作系統(tǒng)。

  • QChildEvent 對(duì)象會(huì)在添加或刪除一個(gè)child時(shí),發(fā)送給另一個(gè) QObject,這些事件來自于你的程序中。

關(guān)于事件,有一個(gè)很重要的事情,那就是事件不會(huì)一產(chǎn)生就發(fā)送給需要處理這個(gè)事件的對(duì)象;而是放到事件隊(duì)列中,然后再發(fā)送。事件分發(fā)器會(huì)循環(huán)處理事件隊(duì)列,把每個(gè)在隊(duì)列中的事件分發(fā)給相應(yīng)的對(duì)象,因此這個(gè)又叫做事件循環(huán)。從概念上講,事件循環(huán)看起來是這樣的:

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

在 Qt 的使用中,通過調(diào)用 QCoreApplication::exec() 進(jìn)入 Qt 的主消息循環(huán);這個(gè)函數(shù)會(huì)阻塞,直到調(diào)用 QCoreApplication::exit() 或 QCoreApplication::quit(),結(jié)束消息循環(huán)。

函數(shù) "wait_for_more_events()" 會(huì)阻塞(不是忙等)直到有事件產(chǎn)生。稍加考慮,我們就會(huì)發(fā)現(xiàn),在這時(shí)事件一定是從外部產(chǎn)生的(事件分發(fā)器已經(jīng)結(jié)束并且也沒有新的事件在事件隊(duì)列中等待分發(fā))。因此,事件循環(huán)可以在以下幾種情況下被喚醒:

  • 窗口管理器(鍵盤/鼠標(biāo)點(diǎn)擊,和窗口的交互,等)

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

  • 計(jì)時(shí)器(計(jì)時(shí)器超時(shí))

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

在 Unix-like 系統(tǒng)中,窗口管理器的活動(dòng)(例如 X11)是通過套接字(socket)(Unix Domain or TCP/IP)通知給應(yīng)用程序的,因?yàn)榭蛻舳耸峭ㄟ^套接字和 X Server 通信的。如果我們使用內(nèi)部的 socketpair(2) 來實(shí)現(xiàn)跨線程的消息發(fā)送,那么我們要做的就是通過某些活動(dòng)喚醒消息循環(huán):

  • 套接字(socket)

  • 計(jì)時(shí)器

系統(tǒng)調(diào)用 select(2) 是這么工作的:它監(jiān)聽著一個(gè)活動(dòng)描述符的集合,如果一段時(shí)間(可配置超時(shí)事件)內(nèi)都沒有活動(dòng)那么它就會(huì)超時(shí)。Qt 所需要做的就是把 select 返回的結(jié)果轉(zhuǎn)化為一個(gè) QEvent 對(duì)象(子類對(duì)象)然后把它放入事件隊(duì)列中?,F(xiàn)在你應(yīng)該知道消息循環(huán)內(nèi)部事怎么回事兒了QT多線程深入分析

哪些東西需要事件循環(huán)?

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

  • Widget 繪圖(painting)和交互:當(dāng)接收到 QPaintEvent 對(duì)象時(shí),函數(shù) QWidget::paintEvent() 會(huì)被調(diào)用,QPaintEvent 對(duì)象的產(chǎn)生,有可能是調(diào)用 QWidget::update() (應(yīng)用程序內(nèi)部調(diào)用) 函數(shù),或者來自窗口管理器(例如:把一個(gè)隱藏的窗口顯示出來)。其他類型的交互(鼠標(biāo)、鍵盤,等)也是一樣的:這些事件都需要一個(gè)事件循環(huán)來分發(fā)事件。

  • 計(jì)時(shí)器:簡(jiǎn)單說,當(dāng) select(2) 或類似的調(diào)用超時(shí)的時(shí)候,計(jì)時(shí)器超時(shí)事件被觸發(fā),因此你需要消息循換來處理這些調(diào)用。

  • 網(wǎng)絡(luò)通信:所有 low-level 的 Qt 網(wǎng)絡(luò)通信類(QTcpSocket, QUdpSocket, QTcpServer,等)都設(shè)計(jì)為異步的。當(dāng)調(diào)用 read() 函數(shù)時(shí),它們僅僅返回當(dāng)前可用的數(shù)據(jù),當(dāng)調(diào)用 write() 函數(shù)時(shí),它們會(huì)安排稍后再寫。僅僅當(dāng)程序返回消息循換的時(shí)候,讀/寫操作才真正發(fā)生。注意雖然提供有同步的方法(那些以 waitFor* 命名的函數(shù)),但是它們并不好用,因?yàn)樵诘却耐瑫r(shí)他們阻塞了消息循換。像 QNetworkAccessManager 這樣的 high-level 類,同樣需要消息循換,但不提供任何同步調(diào)用的接口。

阻塞消息循換

在討論為什么我們不應(yīng)該阻塞消息循換之前,先說明一下“阻塞”的含義是什么。想像一下,有一個(gè)在點(diǎn)擊時(shí)可以發(fā)送信號(hào)的按鈕,信號(hào)綁定到我們的工作類對(duì)象的一個(gè)槽函數(shù)上,這個(gè)槽函數(shù)會(huì)做很多工作。當(dāng)你點(diǎn)擊按鈕時(shí),函數(shù)調(diào)用??雌饋響?yīng)該像下面這樣(棧底在上):

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

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

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

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

強(qiáng)制分發(fā)事件

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

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

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 不提供阻塞的接口,同時(shí)需要一個(gè)消息循環(huán)。我們進(jìn)入了一個(gè)局部的 QEventLoop,當(dāng) reply 發(fā)出 finished 信號(hào)時(shí),這個(gè)事件循環(huán)就結(jié)束了。

通過“其他路徑”重入消息循換時(shí)需要特別小心:這可能導(dǎo)致不期望的遞歸!回到剛才的按鈕例子中。如果我們?cè)俨酆瘮?shù) doWork() 中調(diào)用 QCoreApplication::processEvents() ,同時(shí)用戶再次點(diǎn)擊了按鈕,這個(gè)槽函數(shù) doWork() 會(huì)再一次被調(diào)用:

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.

一個(gè)快速簡(jiǎn)單的規(guī)避辦法是給 QCoreApplication::processEvents() 傳入一個(gè)參數(shù) QEventLoop::ExcludeUserInputEvents,它會(huì)告訴消息循換不要分發(fā)任何用戶輸入的事件(這些事件會(huì)停留在隊(duì)列中)。

幸運(yùn)的是,同樣的問題不會(huì)出現(xiàn)在刪除事件中(調(diào)用 QObject::deleteLater() 會(huì)發(fā)送該事件到事件隊(duì)列中)。事實(shí)上,Qt 使用了特別的辦法來處理它,當(dāng)消息循環(huán)比 deleteLater 調(diào)用發(fā)生的消息循環(huán)更外層時(shí),刪除事件才會(huì)被處理。例如:

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

這不會(huì)導(dǎo)致 object 空懸指針(QDialog::exec() 中的消息循環(huán),比 deleteLater 調(diào)用發(fā)生的地方層次更深)。同樣的事情也會(huì)發(fā)生在 QEventLoop 啟動(dòng)的消息循環(huán)中。我只發(fā)現(xiàn)過一個(gè)例外(在 Qt 4.7.3 中),如果在沒有任何消息循環(huán)的時(shí)候調(diào)用了 deleteLater,那么第一個(gè)啟動(dòng)的消息循環(huán)會(huì)處理這個(gè)消息,刪除該對(duì)象。這是很合理的,因?yàn)?Qt 知道不會(huì)有任何會(huì)執(zhí)行刪除動(dòng)作的“外層”循環(huán),因此會(huì)立即刪除該對(duì)象。

 

Qt 線程類

Qt 支持多線程已經(jīng)很多年(2000 年9月22日發(fā)布的 Qt 2.2 引入了 QThread 類),4.0 版本在所有平臺(tái)上都默認(rèn)開啟多線程支持(多線程支持是可以關(guān)閉的,更多細(xì)節(jié)看這里[doc.qt.nokia.com])。Qt 現(xiàn)在提供了很多類來實(shí)現(xiàn)多線程;下面就來看一下。

QThread

QThread [doc.qt.nokia.com] 是 Qt 中多線程支持的核心的 low-level 類。一個(gè) QThread 對(duì)象表示一個(gè)執(zhí)行的線程。由于 Qt 的跨平臺(tái)特性,QThread 設(shè)法隱藏了不同操作系統(tǒng)在線程操作中的所有平臺(tái)相關(guān)的代碼。

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

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

然后這么使用

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

來啟動(dòng)一個(gè)新的線程。注意,從 Qt 4.4 開始,QThread 不再是抽象類,現(xiàn)在虛函數(shù) QThread::run() 有了調(diào)用 QThread::exec() 的默認(rèn)實(shí)現(xiàn);它會(huì)啟動(dòng)線程自己的消息循環(huán)(稍后詳細(xì)說明)。

QRunnable 和 QThreadPool

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

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

我們使用 QThreadPool [doc.qt.nokia.com] 類,它管理著一個(gè)線程池,來真正運(yùn)行一個(gè) QRunnable 對(duì)象。當(dāng)調(diào)用 QThreadPool::start(runnable) 時(shí),我們將 QRunnable 對(duì)象放入 QThreadPool 的執(zhí)行隊(duì)列中;當(dāng)線程可用時(shí),QRunnable 對(duì)像會(huì)啟動(dòng),然后在線程中執(zhí)行。所有的 Qt 應(yīng)用程序都有一個(gè)全局的線程池,可以通過調(diào)用  QThreadPool::globalInstance() 來獲得,但是也可以創(chuàng)建一個(gè)私有的 QThreadPool 對(duì)象來顯式的管理。

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

QtConcurrent

QtConcurrent [doc.qt.nokia.com] 是 high-level API,在 QThreadPool 基礎(chǔ)上構(gòu)建而成,它可以應(yīng)用在大部分常用的并行計(jì)算范式中:map [en.wikipedia.org]), reduce [en.wikipedia.org]), 和 filter [en.wikipedia.org]);它同時(shí)提供 QtConcurrent::run() 方法,可以簡(jiǎn)單的在另一個(gè)線程中啟動(dòng)一個(gè)函數(shù)。

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

特性對(duì)比

\QThreadQRunnableQtConcurrent1
high level 接口nny
面向任務(wù)nyy
內(nèi)建支持暫停/恢復(fù)/取消nny
支持優(yōu)先級(jí)ynn
可以運(yùn)行消息循環(huán)ynn




1 QtConcurrent::run 是個(gè)例外,因?yàn)樗鞘褂?QRunnable 實(shí)現(xiàn)的,所以帶有 QRunnable 的特性。

 

線程和QObject

每個(gè)線程一個(gè)消息循環(huán)

到現(xiàn)在為止,我們已經(jīng)討論過“消息循環(huán)”,但討論的僅僅是在一個(gè) Qt 應(yīng)用程序中只有一個(gè)消息循換的情況。但不是下面這種情況:QThread 對(duì)象可以啟動(dòng)一個(gè)自己代表的線程中的消息循換。因此,我們把在 main() 函數(shù)中通過調(diào)用 QCoreApplication::exec()(該函數(shù)只能在主線程中調(diào)用)啟動(dòng)的消息循換叫做主消息循環(huán)。它也叫做 GUI 線程,因?yàn)?UI 相關(guān)的操作只能(應(yīng)該)在該線程中執(zhí)行。一個(gè) QThread 局部消息循換可以通過調(diào)用 QThread::exec() 來啟動(dòng)(在 run() 函數(shù)中):

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

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

一個(gè)線程的消息循環(huán)為所有在這個(gè)線程中的 QObject 對(duì)象分發(fā)消息;默認(rèn)的,它包括所有在這個(gè)線程中創(chuàng)建的對(duì)象,或者從其他線程中移過來的對(duì)象(接下來詳細(xì)說明)。同時(shí),一個(gè) QObject 對(duì)象的線程相關(guān)性是確定的,也就是說這個(gè)對(duì)象生存在這個(gè)線程中。這個(gè)適用于在 QThread 對(duì)象的構(gòu)造函數(shù)中創(chuàng)建的對(duì)象:

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

在創(chuàng)建一個(gè) MyThread 對(duì)象之后,obj,otherObj,yetAnotherObj 的線程相關(guān)性如何?我們必須看看創(chuàng)建這些對(duì)象的線程:它是運(yùn)行 MyThread 構(gòu)造函數(shù)的線程。因此,所有這三個(gè)對(duì)象都不屬于 MyThread 線程,而是創(chuàng)建了 MyThread 對(duì)象的線程(MyThread 對(duì)象也屬于該線程)。

QT多線程深入分析

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

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

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

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

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

  • 你不能在 QThread 類中以 QThread 為父親創(chuàng)建對(duì)象

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

這是因?yàn)?QThread 對(duì)象所在的線程是另外的線程,即,QThread 對(duì)象所在的線程是創(chuàng)建它的線程。

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

跨線程信號(hào)槽

有了這些前提,我們?nèi)绾握{(diào)用另一個(gè)線程中 QObject 對(duì)象的函數(shù)?Qt 提供了一個(gè)非常漂亮和干凈的解決方案:我們發(fā)送一個(gè)事件到線程的消息隊(duì)列中,事件的處理,將調(diào)用我們感興趣的函數(shù)(當(dāng)然這個(gè)線程需要啟動(dòng)一個(gè)事件循環(huán))。該設(shè)施圍繞 Qt 的元對(duì)象編譯器(MOC)提供的方法內(nèi)省而構(gòu)建:因此,信號(hào),槽,函數(shù),只要使用了 Q_INVOKABLE 宏,那么就可以從另外的線程調(diào)用它。

QMetaObject::invokeMethod() 靜態(tài)方法為我們實(shí)現(xiàn)了這個(gè)功能:

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

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

跨線程的信號(hào)槽工作方式是類似的。當(dāng)我們將信號(hào)和曹連接時(shí),QObject::connect 函數(shù)的第5個(gè)參數(shù)可以指定連接的類型:

  • direct connection:意思是槽函數(shù)會(huì)在信號(hào)發(fā)送的線程中直接被調(diào)用。

  • queued connection:意思是事件會(huì)發(fā)送到接收者所在線程的消息隊(duì)列中,消息循環(huán)會(huì)稍后處理該事件然后調(diào)用槽函數(shù)。

  • blocking queued connection:和 queued connection 類似,但是發(fā)送線程會(huì)阻塞,直到接收者所在線程的消息循環(huán)處理了該事件,調(diào)用了槽函數(shù)之后,才會(huì)返回;

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

自動(dòng)連接(默認(rèn)值),如果發(fā)送者和接收者在同一線程它和直接連接(direct connection)的行為是一樣的;如果發(fā)送者和接收者在不同的線程它和隊(duì)列連接(queued connection)的行為是一樣的。

因?yàn)榘l(fā)送者所在的線程和無關(guān)緊要的。例如:

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();

信號(hào) aSignal() 會(huì)在一個(gè)新的線程中發(fā)送(Thread 對(duì)象創(chuàng)建的線程);因?yàn)檫@不是 Object  對(duì)象所在的線程(但這時(shí),Object 對(duì)象與 Thread 對(duì)象在同一個(gè)線程中,再次強(qiáng)調(diào),發(fā)送者所在線程是無關(guān)緊要的),這時(shí)將使用 queued connection。

另一個(gè)常見的陷阱:

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();

當(dāng)“obj” 發(fā)送 aSignal() 信號(hào)時(shí),將會(huì)使用哪種連接類型?你應(yīng)該已經(jīng)猜到了:direct connection。這是因?yàn)?Thread 對(duì)象所在線程就是信號(hào)發(fā)送的線程。在槽函數(shù) aSlot() 中,我們可能訪問 Thread 類的成員,而同時(shí) run() 函數(shù)可能也在訪問,他們會(huì)同時(shí)進(jìn)行:這是完美的災(zāi)難配方。

另一個(gè)例子,或許也是最重要的一個(gè):

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 對(duì)象所在線程啟動(dòng)一個(gè)消息循環(huán)。

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

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

這確實(shí)可以工作(因?yàn)楝F(xiàn)在線程對(duì)象所在的線程的確改變了),但是這是個(gè)非常糟糕的設(shè)計(jì)。錯(cuò)誤在于我們誤解了 thread 對(duì)象(QThread 子類)的目的:QThread 對(duì)象不是線程本身;它是用于管理線程的,因此它應(yīng)該在另一個(gè)線程中使用(通常就是創(chuàng)建它的線程)。

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

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();

應(yīng)該做&不應(yīng)該做

你可以…

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

你不應(yīng)該…

  • 使用 moveToThread(this)

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

  • 在 QThread 子類中增加槽函數(shù):它們會(huì)在“錯(cuò)誤的”線程中被調(diào)用,不是在 QThread 管理的線程中,而是在 QThread 對(duì)象創(chuàng)建的線程,迫使你使用 direct connection 或使用 moveToThread(this) 函數(shù)。

  • 使用 QThread::terminate 函數(shù)。

禁止…

  • 在線程還在運(yùn)行時(shí)退出程序。使用 QThread::wait 等待線程終止。

  • 當(dāng) QThread 管理的線程還在運(yùn)行時(shí),刪除 QThread 對(duì)象。如果你想要“自動(dòng)析構(gòu)”,你可以將 finished() 信號(hào)連接到 deleteLater() 槽函數(shù)上。

 

什么時(shí)候應(yīng)該使用線程?

當(dāng)使用阻塞 API 時(shí)

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

地址解析(只是舉個(gè)例子,不是在討論蹩腳的第三方 API。這是每一個(gè) C 語言函數(shù)庫中包含的東西)就是一個(gè)很好的例子,它把主機(jī)名轉(zhuǎn)換為地址。它會(huì)調(diào)用域名解析系統(tǒng)DNS)來查詢。雖然一般情況下,它會(huì)立即返回,但是遠(yuǎn)程服務(wù)器有可能故障,有可能丟包,有可能網(wǎng)絡(luò)突然中斷,等等。簡(jiǎn)而言之,它可能需要等待很長(zhǎng)時(shí)間才相應(yīng)我們發(fā)出的請(qǐng)求。

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

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

當(dāng)你想要充分利用多CPU時(shí)

多線程可以讓你的程序更好的利用多處理器系統(tǒng)。每個(gè)線程是由操作系統(tǒng)獨(dú)立調(diào)用的,如果你的程序運(yùn)行在這樣的機(jī)器上,線程調(diào)度就可以讓多個(gè)處理器同時(shí)運(yùn)行不同的線程。

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

當(dāng)你不想被阻塞時(shí)

呃…從一個(gè)例子開始會(huì)更好。

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

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

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

socket 活動(dòng)(也就是 socket 是否可讀)是由 Qt 的事件循環(huán)還管理的。阻塞事件循環(huán)會(huì)導(dǎo)致傳輸性能下降,因?yàn)檫@時(shí)沒有人會(huì)被告知現(xiàn)在數(shù)據(jù)已經(jīng)可讀(所以沒有人會(huì)去讀取數(shù)據(jù))。

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

總結(jié)一下,這個(gè)過程發(fā)生的事情:

  • Webkit 發(fā)起請(qǐng)求;

  • 一些響應(yīng)數(shù)據(jù)開始到達(dá);

  • Webkit 開始使用到達(dá)的數(shù)據(jù)來網(wǎng)頁布局,阻塞了事件循環(huán);

  • 沒有了事件循環(huán),操作系統(tǒng)接收到了數(shù)據(jù),但沒有人從 QNetworkAccessManager 的 socket 中讀取數(shù)據(jù);

  • 內(nèi)核緩沖將被其他數(shù)據(jù)填充,從而導(dǎo)致傳輸效率下降。

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

注意,由于 QNetworkAccessManager 和 QNetworkReply 都是 QObject,它們都不是線程安全的,因此你不能將它移動(dòng)到另一個(gè)線程然后繼續(xù)在你的線程中繼續(xù)使用它,因?yàn)槟憧赡軓膬蓚€(gè)線程中同時(shí)訪問它:你自己的線程和它所在的線程,因?yàn)樗诘南⒀h(huán)會(huì)將事件分發(fā)給它處理。

在 Qt 4.8 中,QNetworkAccessManager 現(xiàn)在默認(rèn)使用單獨(dú)的線程處理 HTTP 請(qǐng)求,因此 UI 反應(yīng)慢和系統(tǒng)緩沖被填充過快的問題得以解決。

 

什么時(shí)候不應(yīng)該使用線程?

計(jì)時(shí)器

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

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

然后會(huì)發(fā)現(xiàn)這阻塞了事件循環(huán),然后決定使用線程來解決:

// 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()
        }
    }
};

一個(gè)更好更簡(jiǎn)單的辦法是使用計(jì)時(shí)器,一個(gè)超時(shí)時(shí)間為1秒的 QTimer [doc.qt.nokia.com] 對(duì)象,和 doWork() 槽函數(shù):

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;
};

我們所需要做的就是啟動(dòng)一個(gè)消息循環(huán),然后 doWork() 函數(shù)會(huì)每一秒調(diào)用一次。

網(wǎng)絡(luò)通信/狀態(tài)機(jī)

下面是一個(gè)非常常見的網(wǎng)絡(luò)通信的設(shè)計(jì):

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*() 函數(shù)調(diào)用會(huì)阻塞消息循環(huán),凍結(jié) UI,等等。注意,上面的代碼沒有任何的錯(cuò)誤處理,不然它會(huì)更繁瑣。上面的錯(cuò)誤在于我們忘記了最初網(wǎng)絡(luò)設(shè)計(jì)的就是異步的,如果我們使用同步處理,那就是朝自己的腳開槍。解決上面的問題,許多人會(huì)簡(jiǎn)單的把它移動(dòng)到不同的線程中。

另一個(gè)更抽象的例子:

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

它和上面網(wǎng)絡(luò)的例子有著同樣的陷阱。

讓我們退一步,從更高的視角來看看我們構(gòu)建的東西,我們構(gòu)建了一個(gè)狀態(tài)機(jī)來處理輸入。

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

  • 連接中 –> 已連接 (發(fā)出 connected() 信號(hào))

  • 已連接 –> 發(fā)送登陸數(shù)據(jù)(發(fā)送登陸數(shù)據(jù)到服務(wù)器)

  • 發(fā)送登陸數(shù)據(jù) –> 登陸成功(服務(wù)器返回 ACK)

  • 發(fā)送登陸數(shù)據(jù) –> 登陸失?。ǚ?wù)器返回 NACK)

等等。

現(xiàn)在,我們有很多辦法來構(gòu)建一個(gè)狀態(tài)機(jī)(Qt 就為我們提供了一個(gè)可使用的類:QStateMachine [doc.qt.nokia.com]),最簡(jiǎn)單的辦法就是使用枚舉(整型)來記錄當(dāng)前的狀態(tài)。我們可以重寫上面的代碼:

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” 對(duì)象和“ready()”信號(hào)是什么?我們想要的是:拿網(wǎng)絡(luò)例子來說,我們想要把 QAbstractSocket::connected() 和 QIODevice::readyRead() 連接到我們的槽函數(shù)上。當(dāng)然,如果再多些槽函數(shù)更好的話,我們也可以增加更多(比如錯(cuò)誤處理的槽函數(shù),由 QAbstractSocket::error() 信號(hào)來發(fā)起)。這是真正的異步,信號(hào)驅(qū)動(dòng)的設(shè)計(jì)!

把任務(wù)分解成小塊

想想一下我們有個(gè)很耗時(shí)但是無法移動(dòng)到其它線程的任務(wù)(或者根本不能移動(dòng)到其它線程,因?yàn)樗赡鼙仨氃?UI 線程中執(zhí)行)。如果我們把任務(wù)分解成小塊,那么我們就可以返回消息循環(huán),讓消息循環(huán)分發(fā)事件,然后讓它調(diào)用處理后續(xù)任務(wù)塊的函數(shù)。如果我們還記得 queued connection 如何實(shí)現(xiàn)的話,那就很容易解決這個(gè)問題了:事件發(fā)送到接收者所在的事件循環(huán)中,當(dāng)事件被分發(fā)的時(shí)候,相應(yīng)的槽函數(shù)被調(diào)用。

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

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));
 
    }
};

因?yàn)檫@里沒有線程調(diào)用,所以它可以很容易的暫停/恢復(fù)/取消任務(wù),也可以很容易的得到計(jì)算結(jié)果。

 

一些例子

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問一下細(xì)節(jié)

免責(zé)聲明:本站發(fā)布的內(nèi)容(圖片、視頻和文字)以原創(chuàng)、轉(zhuǎn)載和分享為主,文章觀點(diǎn)不代表本網(wǎng)站立場(chǎng),如果涉及侵權(quán)請(qǐng)聯(lián)系站長(zhǎng)郵箱:is@yisu.com進(jìn)行舉報(bào),并提供相關(guān)證據(jù),一經(jīng)查實(shí),將立刻刪除涉嫌侵權(quán)內(nèi)容。

qt
AI