#(75):線程總結
前面我們已經詳細介紹過有關線程的一些值得注意的事項。現在我們開始對線程做一些總結。
有關線程,你可以做的是:
* 在`QThread`子類添加信號。這是絕對安全的,并且也是正確的(前面我們已經詳細介紹過,發送者的線程依附性沒有關系)
不應該做的是:
* 調用`moveToThread(this)`函數
* 指定連接類型:這通常意味著你正在做錯誤的事情,比如將`QThread`控制接口與業務邏輯混雜在了一起(而這應該放在該線程的一個獨立對象中)
* 在`QThread`子類添加槽函數:這意味著它們將在錯誤的線程被調用,也就是`QThread`對象所在線程,而不是`QThread`對象管理的線程。這又需要你指定連接類型或者調用`moveToThread(this)`函數
* 使用`QThread::terminate()`函數
不能做的是:
* 在線程還在運行時退出程序。使用`QThread::wait()`函數等待線程結束
* 在`QThread`對象所管理的線程仍在運行時就銷毀該對象。如果你需要某種“自行銷毀”的操作,你可以把`finished()`信號同`deleteLater()`槽連接起來
那么,下面一個問題是:我什么時候應該使用線程?
**首先,當你不得不使用同步 API 的時候。**
如果你需要使用一個沒有非阻塞 API 的庫或代碼(所謂非阻塞 API,很大程度上就是指信號槽、事件、回調等),那么,避免事件循環被阻塞的解決方案就是使用進程或者線程。不過,由于開啟一個新的工作進程,讓這個進程去完成任務,然后再與當前進程進行通信,這一系列操作的代價都要比開啟線程要昂貴得多,所以,線程通常是最好的選擇。
一個很好的例子是地址解析服務。注意我們這里并不討論任何第三方 API,僅僅假設一個有這樣功能的庫。這個庫的工作是將一個主機名轉換成地址。這個過程需要去到一個系統(也就是域名系統,Domain Name System, DNS)執行查詢,這個系統通常是一個遠程系統。一般這種響應應該瞬間完成,但是并不排除遠程服務器失敗、某些包可能會丟失、網絡可能失去鏈接等等。簡單來說,我們的查詢可能會等幾十秒鐘。
UNIX 系統上的標準 API 是阻塞的(不僅是舊的`gethostbyname(3)`,就連新的`getservbyname(3)`和`getaddrinfo(3)`也是一樣)。Qt 提供的`QHostInfo`類同樣用于地址解析,默認情況下,內部使用一個`QThreadPool`提供后臺運行方式的查詢(如果關閉了 Qt 的線程支持,則提供阻塞式 API)。
另外一個例子是圖像加載和縮放。`QImageReader`和`QImage`只提供了阻塞式 API,允許我們從設備讀取圖片,或者是縮放到不同的分辨率。如果你需要處理很大的圖像,這種任務會花費幾十秒鐘。
**其次,當你希望擴展到多核應用的時候。**
線程允許你的程序利用多核系統的優勢。每一個線程都可以被操作系統獨立調度,如果你的程序運行在多核機器上,調度器很可能會將每一個線程分配到各自的處理器上面運行。
舉個例子,一個程序需要為很多圖像生成縮略圖。一個具有固定 n 個線程的線程池,每一個線程交給系統中的一個可用的 CPU 進行處理(我們可以使用`QThread::idealThreadCount()`獲取可用的 CPU 數)。這樣的調度將會把圖像縮放工作交給所有線程執行,從而有效地提升效率,幾乎達到與 CPU 數的線性提升(實際情況不會這么簡單,因為有時候 CPU 并不是瓶頸所在)。
**第三,當你不想被別人阻塞的時候。**
這是一個相當高級的話題,所以你現在可以暫時不看這段。這個問題的一個很好的例子是在 WebKit 中使用`QNetworkAccessManager`。WebKit 是一個現代的瀏覽器引擎。它幫助我們展示網頁。Qt 中的`QWebView`就是使用的 WebKit。
`QNetworkAccessManager`則是 Qt 處理 HTTP 請求和響應的通用類。我們可以將它看做瀏覽器的網絡引擎。在 Qt 4.8 之前,這個類沒有使用任何協助工作線程,所有的網絡處理都是在`QNetworkAccessManager`及其`QNetworkReply`所在線程完成。
雖然在網絡處理中不使用線程是一個好主意,但它也有一個很大的缺點:如果你不能及時從 socket 讀取數據,內核緩沖區將會被填滿,于是開始丟包,傳輸速度將會直線下降。
socket 活動(也就是從一個 socket 讀取一些可用的數據)是由 Qt 的事件循環管理的。因此,阻塞事件循環將會導致傳輸性能的損失,因為沒有人會獲得有數據可讀的通知,因此也就沒有人能夠讀取這些數據。
但是什么會阻塞事件循環?最壞的答案是:WebKit 自己!只要收到數據,WebKit 就開始生成網頁布局。不幸的是,這個布局的過程非常復雜和耗時,因此它會阻塞事件循環。盡管阻塞時間很短,但是足以影響到正常的數據傳輸(寬帶連接在這里發揮了作用,在很短時間內就可以塞滿內核緩沖區)。
總結一下上面所說的內容:
* WebKit 發起一次請求
* 從服務器響應獲取一些數據
* WebKit 利用到達的數據開始進行網頁布局,阻塞事件循環
* 由于事件循環被阻塞,也就沒有了可用的事件循環,于是操作系統接收了到達的數據,但是卻不能從`QNetworkAccessManager`的 socket 讀取
* 內核緩沖區被填滿,傳輸速度變慢
網頁的整體加載時間被自身的傳輸速度的降低而變得越來越壞。
注意,由于`QNetworkAccessManager`和`QNetworkReply`都是`QObject`,所以它們都不是線程安全的,因此你不能將它們移動到另外的線程繼續使用。因為它們可能同時有兩個線程訪問:你自己的和它們所在的線程,這是因為派發給它們的事件會由后面一個線程的事件循環發出,但你不能確定哪一線程是“后面一個”。
Qt 4.8 之后,`QNetworkAccessManager`默認會在一個獨立的線程處理 HTTP 請求,所以導致 GUI 失去響應以及操作系統緩沖區過快填滿的問題應該已經被解決了。
那么,什么情況下不應該使用線程呢?
**定時器**
這可能是最容易誤用線程的情況了。如果我們需要每隔一段時間調用一個函數,很多人可能會這么寫代碼:
~~~
// 最錯誤的代碼
while (condition) {
doWork();
sleep(1); // C 庫里面的 sleep(3) 函數
}
~~~
當讀過我們前面的文章之后,可能又會引入線程,改成這樣的代碼:
~~~
// 錯誤的代碼
class Thread : public QThread {
protected:
void run() {
while (condition) {
// 注意,如果我們要在別的線程修改 condition,那么它也需要加鎖
doWork();
sleep(1); // 這次是 QThread::sleep()
}
}
};
~~~
最好最簡單的實現是使用定時器,比如`QTimer`,設置 1s 超時,然后將`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;
};
~~~
**網絡/狀態機**
下面是一個很常見的處理網絡操作的設計模式:
~~~
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();
/* ... */
~~~
在經過前面幾章的介紹之后,不用多說,我們就會發現這里的問題:大量的`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);
/* ... */
~~~
這段抽象的代碼與前面網絡的例子有“異曲同工之妙”。
讓我們回過頭來看看這段代碼究竟是做了什么:我們實際是想創建一個狀態機,這個狀態機要根據用戶的輸入作出合理的響應。例如我們網絡的例子,我們實際是想要構建這樣的東西:
~~~
空閑 → 正在連接(調用<code>connectToHost()</code>)
正在連接 → 成功連接(發出<code>connected()</code>信號)
成功連接 → 發送登錄數據(將登錄數據發送到服務器)
發送登錄數據 → 登錄成功(服務器返回 ACK)
發送登錄數據 → 登錄失敗(服務器返回 NACK)
~~~
以此類推。
既然知道我們的實際目的,我們就可以修改代碼來創建一個真正的狀態機(Qt 甚至提供了一個狀態機類:`QStateMachine`)。創建狀態機最簡單的方法是使用一個枚舉來記住當前狀態。我們可以編寫如下代碼:
~~~
class Object : public QObject
{
Q_OBJECT
enum State {
State1, State2, State3 /* ... */
};
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;
/* ... */
}
}
};
~~~
`source`對象是哪來的?這個對象其實就是我們關心的對象:例如,在網絡的例子中,我們可能希望把 socket 的`QAbstractSocket::connected()`或者`QIODevice::readyRead()`信號與我們的槽函數連接起來。當然,我們很容易添加更多更合適的代碼(比如錯誤處理,使用`QAbstractSocket::error()`信號就可以了)。這種代碼是真正異步、信號驅動的設計。
**將任務分割成若干部分**
假設我們有一個很耗時的計算,我們不能簡單地將它移動到另外的線程(或者是我們根本無法移動它,比如這個任務必須在 GUI 線程完成)。如果我們將這個計算任務分割成小塊,那么我們就可以及時返回事件循環,從而讓事件循環繼續派發事件,調用處理下一個小塊的函數。回一下如何實現隊列連接,我們就可以輕松完成這個任務:將事件提交到接收對象所在線程的事件循環;當事件發出時,響應函數就會被調用。
我們可以使用`QMetaObject::invokeMethod()`函數,通過指定`Qt::QueuedConnection`作為調用類型來達到相同的效果。不過這要求函數必須是內省的,也就是說這個函數要么是一個槽函數,要么標記有`Q_INVOKABLE`宏。如果我們還需要傳遞參數,我們需要使用`qRegisterMetaType()`函數將參數注冊到 Qt 元類型系統。下面是代碼示例:
~~~
class Worker : public QObject
{
Q_OBJECT
public slots:
void startProcessing()
{
processItem(0);
}
void processItem(int index)
{
/* 處理 items[index] ... */
if (index < numberOfItems) {
QMetaObject::invokeMethod(this,
"processItem",
Qt::QueuedConnection,
Q_ARG(int, index + 1));
}
}
};
~~~
由于沒有任何線程調用,所以我們可以輕易對這種計算任務執行暫停/恢復/取消,以及獲取結果。
至此,我們利用五個章節將有關線程的問題簡單介紹了下。線程應該說是全部設計里面最復雜的部分之一,所以這部分內容也會比較困難。在實際運用中肯定會更多的問題,這就只能讓我們具體分析了。
- (1)序
- (2)Qt 簡介
- (3)Hello, world!
- (4)信號槽
- (5)自定義信號槽
- (6)Qt 模塊簡介
- (7)MainWindow 簡介
- (8)添加動作
- (9)資源文件
- (10)對象模型
- (11)布局管理器
- (12)菜單欄、工具欄和狀態欄
- (13)對話框簡介
- (14)對話框數據傳遞
- (15)標準對話框 QMessageBox
- (16)深入 Qt5 信號槽新語法
- (17)文件對話框
- (18)事件
- (19)事件的接受與忽略
- (21)事件過濾器
- (22)事件總結
- (23)自定義事件
- (24)Qt 繪制系統簡介
- (25)畫刷和畫筆
- (26)反走樣
- (27)漸變
- (28)坐標系統
- (29)繪制設備
- (30)Graphics View Framework
- (31)貪吃蛇游戲(1)
- (32)貪吃蛇游戲(2)
- (33)貪吃蛇游戲(3)
- (34)貪吃蛇游戲(4)
- (35)文件
- (36)二進制文件讀寫
- (37)文本文件讀寫
- (38)存儲容器
- (39)遍歷容器
- (40)隱式數據共享
- (41)model/view 架構
- (42)QListWidget、QTreeWidget 和 QTableWidget
- (43)QStringListModel
- (44)QFileSystemModel
- (45)模型
- (46)視圖和委托
- (47)視圖選擇
- (48)QSortFilterProxyModel
- (49)自定義只讀模型
- (50)自定義可編輯模型
- (51)布爾表達式樹模型
- (52)使用拖放
- (53)自定義拖放數據
- (54)剪貼板
- (55)數據庫操作
- (56)使用模型操作數據庫
- (57)可視化顯示數據庫數據
- (58)編輯數據庫外鍵
- (59)使用流處理 XML
- (60)使用 DOM 處理 XML
- (61)使用 SAX 處理 XML
- (62)保存 XML
- (63)使用 QJson 處理 JSON
- (64)使用 QJsonDocument 處理 JSON
- (65)訪問網絡(1)
- (66)訪問網絡(2)
- (67)訪問網絡(3)
- (68)訪問網絡(4)
- (69)進程
- (70)進程間通信
- (71)線程簡介
- (72)線程和事件循環
- (73)Qt 線程相關類
- (74)線程和 QObject
- (75)線程總結
- (76)QML 和 QtQuick 2
- (77)QML 語法
- (78)QML 基本元素
- (79)QML 組件
- (80)定位器
- (81)元素布局
- (82)輸入元素
- (83)Qt Quick Controls
- (84)Repeater
- (85)動態視圖
- (86)視圖代理
- (87)模型-視圖高級技術
- (88)Canvas
- (89)Canvas(續)