#(72):線程和事件循環
前面一章我們簡單介紹了如何使用`QThread`實現線程。現在我們開始詳細介紹如何“正確”編寫多線程程序。我們這里的大部分內容來自于[Qt的一篇Wiki文檔](http://qt-project.org/wiki/Threads_Events_QObjects),有興趣的童鞋可以去看原文。
在介紹在以前,我們要認識兩個術語:
* **可重入的(Reentrant)**:如果多個線程可以在同一時刻調用一個類的所有函數,并且保證每一次函數調用都引用一個唯一的數據,就稱這個類是可重入的(Reentrant means that all the functions in the referenced class can be called simultaneously by multiple threads, provided that each invocation of the functions reference unique data.)。大多數 C++ 類都是可重入的。類似的,一個函數被稱為可重入的,如果該函數允許多個線程在同一時刻調用,而每一次的調用都只能使用其獨有的數據。全局變量就不是函數獨有的數據,而是共享的。換句話說,這意味著類或者函數的使用者必須使用某種額外的機制(比如鎖)來控制對對象的實例或共享數據的序列化訪問。
* **線程安全(Thread-safe)**:如果多個線程可以在同一時刻調用一個類的所有函數,即使每一次函數調用都引用一個共享的數據,就說這個類是線程安全的(Threadsafe means that all the functions in the referenced class can be called simultaneously by multiple threads even when each invocation references shared data.)。如果多個線程可以在同一時刻訪問函數的共享數據,就稱這個函數是線程安全的。
進一步說,對于一個類,如果不同的實例可以被不同線程同時使用而不受影響,就說這個類是可重入的;如果這個類的所有成員函數都可以被不同線程同時調用而不受影響,即使這些調用針對同一個對象,那么我們就說這個類是線程安全的。由此可以看出,線程安全的語義要強于可重入。接下來,我們從事件開始討論。之前我們說過,Qt 是事件驅動的。在 Qt 中,事件由一個普通對象表示(`QEvent`或其子類)。這是事件與信號的一個很大區別:事件總是由某一種類型的對象表示,針對某一個特殊的對象,而信號則沒有這種目標對象。所有`QObject`的子類都可以通過覆蓋`QObject::event()`函數來控制事件的對象。
事件可以由程序生成,也可以在程序外部生成。例如:
* `QKeyEvent`和`QMouseEvent`對象表示鍵盤或鼠標的交互,通常由系統的窗口管理器產生;
* `QTimerEvent`事件在定時器超時時發送給一個`QObject`,定時器事件通常由操作系統發出;
* `QChildEvent`在增加或刪除子對象時發送給一個`QObject`,這是由 Qt 應用程序自己發出的。
需要注意的是,與信號不同,事件并不是一產生就被分發。事件產生之后被加入到一個隊列中(這里的隊列含義同數據結構中的概念,先進先出),該隊列即被稱為事件隊列。事件分發器遍歷事件隊列,如果發現事件隊列中有事件,那么就把這個事件發送給它的目標對象。這個循環被稱作事件循環。事件循環的偽代碼描述大致如下所示:
~~~
while (is_active)
{
while (!event_queue_is_empty) {
dispatch_next_event();
}
wait_for_more_events();
}
~~~
正如前面所說的,調用`QCoreApplication::exec()`?函數意味著進入了主循環。我們把事件循環理解為一個無限循環,直到`QCoreApplication::exit()`或者`QCoreApplication::quit()`被調用,事件循環才真正退出。
偽代碼里面的`while`會遍歷整個事件隊列,發送從隊列中找到的事件;`wait_for_more_events()`函數則會阻塞事件循環,直到又有新的事件產生。我們仔細考慮這段代碼,在`wait_for_more_events()`函數所得到的新的事件都應該是由程序外部產生的。因為所有內部事件都應該在事件隊列中處理完畢了。因此,我們說事件循環在`wait_for_more_events()`函數進入休眠,并且可以被下面幾種情況喚醒:
* 窗口管理器的動作(鍵盤、鼠標按鍵按下、與窗口交互等);
* 套接字動作(網絡傳來可讀的數據,或者是套接字非阻塞寫等);
* 定時器;
* 由其它線程發出的事件(我們會在后文詳細解釋這種情況)。
在類 UNIX 系統中,窗口管理器(比如 X11)會通過套接字(Unix Domain 或 TCP/IP)向應用程序發出窗口活動的通知,因為客戶端就是通過這種機制與 X 服務器交互的。如果我們決定要實現基于內部的`socketpair(2)`函數的跨線程事件的派發,那么窗口的管理活動需要喚醒的是:
* 套接字 socket
* 定時器 timer
這也正是`select(2)`系統調用所做的:它監視窗口活動的一組描述符,如果在一定時間內沒有活動,它會發出超時消息(這種超時是可配置的)。Qt 所要做的,就是把`select()`的返回值轉換成一個合適的`QEvent`子類的對象,然后將其放入事件隊列。好了,現在你已經知道事件循環的內部機制了。
至于為什么需要事件循環,我們可以簡單列出一個清單:
* **組件的繪制與交互**:`QWidget::paintEvent()`會在發出`QPaintEvent`事件時被調用。該事件可以通過內部`QWidget::update()`調用或者窗口管理器(例如顯示一個隱藏的窗口)發出。所有交互事件(鍵盤、鼠標)也是類似的:這些事件都要求有一個事件循環才能發出。
* **定時器**:長話短說,它們會在`select(2)`或其他類似的調用超時時被發出,因此你需要允許 Qt 通過返回事件循環來實現這些調用。
* **網絡**:所有低級網絡類(`QTcpSocket`、`QUdpSocket`以及`QTcpServer`等)都是異步的。當你調用`read()`函數時,它們僅僅返回已可用的數據;當你調用`write()`函數時,它們僅僅將寫入列入計劃列表稍后執行。只有返回事件循環的時候,真正的讀寫才會執行。注意,這些類也有同步函數(以`waitFor`開頭的函數),但是它們并不推薦使用,就是因為它們會阻塞事件循環。高級的類,例如`QNetworkAccessManager`則根本不提供同步 API,因此必須要求事件循環。
有了事件循環,你就會想怎樣阻塞它。阻塞它的理由可能有很多,例如我就想讓`QNetworkAccessManager`同步執行。在解釋為什么**永遠不要阻塞事件循環**之前,我們要了解究竟什么是“阻塞”。假設我們有一個按鈕`Button`,這個按鈕在點擊時會發出一個信號。這個信號會與一個`Worker`對象連接,這個`Worker`對象會執行很耗時的操作。當點擊了按鈕之后,我們觀察從上到下的函數調用堆棧:
~~~
main(int, char **)
QApplication::exec()
[…]
QWidget::event(QEvent *)
Button::mousePressEvent(QMouseEvent *)
Button::clicked()
[…]
Worker::doWork()
~~~
我們在`main()`函數開始事件循環,也就是常見的`QApplication::exec()`函數。窗口管理器偵測到鼠標點擊后,Qt 會發現并將其轉換成`QMouseEvent`事件,發送給組件的`event()`函數。這一過程是通過`QApplication::notify()`函數實現的。注意我們的按鈕并沒有覆蓋`event()`函數,因此其父類的實現將被執行,也就是`QWidget::event()`函數。這個函數發現這個事件是一個鼠標點擊事件,于是調用了對應的事件處理函數,就是`Button::mousePressEvent()`函數。我們重寫了這個函數,發出`Button::clicked()`信號,而正是這個信號會調用`Worker::doWork()`槽函數。有關這一機制我們在前面的事件部分曾有闡述,如果不明白這部分機制,請參考[前面的章節](http://www.devbean.net/2012/10/qt-study-road-2-event-func/)。
在`worker`努力工作的時候,事件循環在干什么?或許你已經猜到了答案:什么都沒做!事件循環發出了鼠標按下的事件,然后等著事件處理函數返回。此時,它一直是阻塞的,直到`Worker::doWork()`函數結束。注意,我們使用了“阻塞”一詞,也就是說,所謂**阻塞事件循環**,意思是沒有事件被派發處理。
在事件就此卡住時,**組件也不會更新自身**(因為`QPaintEvent`對象還在隊列中),**也不會有其它什么交互發生**(還是同樣的原因),**定時器也不會超時**并且**網絡交互會越來越慢直到停止**。也就是說,前面我們大費周折分析的各種依賴事件循環的活動都會停止。這時候,需要窗口管理器會檢測到你的應用程序不再處理任何事件,于是**告訴用戶你的程序失去響應**。這就是為什么我們需要快速地處理事件,并且盡可能快地返回事件循環。
現在,重點來了:我們不可能避免業務邏輯中的耗時操作,那么怎樣做才能既可以執行那些耗時的操作,又不會阻塞事件循環呢?一般會有三種解決方案:第一,我們將任務移到另外的線程(正如我們[上一章](http://www.devbean.net/2013/11/qt-study-road-2-thread-intro/)看到的那樣,不過現在我們暫時略過這部分內容);第二,我們手動強制運行事件循環。想要強制運行事件循環,我們需要在耗時的任務中一遍遍地調用`QCoreApplication::processEvents()`函數。`QCoreApplication::processEvents()`函數會發出事件隊列中的所有事件,并且立即返回到調用者。仔細想一下,我們在這里所做的,就是模擬了一個事件循環。
另外一種解決方案我們在[前面的章節](http://www.devbean.net/2013/11/qt-study-road-2-access-network-4/)提到過:使用`QEventLoop`類重新進入新的事件循環。通過調用`QEventLoop::exec()`函數,我們重新進入新的事件循環,給`QEventLoop::quit()`槽函數發送信號則退出這個事件循環。拿前面的例子來說:
~~~
QEventLoop eventLoop;
connect(netWorker, &NetWorker::finished,
&eventLoop, &QEventLoop::quit);
QNetworkReply *reply = netWorker->get(url);
replyMap.insert(reply, FetchWeatherInfo);
eventLoop.exec();
~~~
`QNetworkReply`沒有提供阻塞式 API,并且要求有一個事件循環。我們通過一個局部的`QEventLoop`來達到這一目的:當網絡響應完成時,這個局部的事件循環也會退出。
前面我們也強調過:通過“其它的入口”進入事件循環要特別小心:因為它會導致遞歸調用!現在我們可以看看為什么會導致遞歸調用了。回過頭來看看按鈕的例子。當我們在`Worker::doWork()`槽函數中調用了`QCoreApplication::processEvents()`函數時,用戶再次點擊按鈕,槽函數`Worker::doWork()又`**一次**被調用:
~~~
main(int, char **)
QApplication::exec()
[…]
QWidget::event(QEvent *)
Button::mousePressEvent(QMouseEvent *)
Button::clicked()
[…]
Worker::doWork() // <strong>第一次調用</strong>
QCoreApplication::processEvents() // <strong>手動發出所有事件</strong>
[…]
QWidget::event(QEvent * ) // <strong>用戶又點擊了一下按鈕…</strong>
Button::mousePressEvent(QMouseEvent *)
Button::clicked() // <strong>又發出了信號…</strong>
[…]
Worker::doWork() // <strong>遞歸進入了槽函數!</strong>
~~~
當然,這種情況也有解決的辦法:我們可以在調用`QCoreApplication::processEvents()`函數時傳入`QEventLoop::ExcludeUserInputEvents`參數,意思是不要再次派發用戶輸入事件(這些事件仍舊會保留在事件隊列中)。
幸運的是,在**刪除事件**(也就是由`QObject::deleteLater()`函數加入到事件隊列中的事件)中,**沒有**這個問題。這是因為刪除事件是由另外的機制處理的。刪除事件只有在事件循環有比較小的“嵌套”的情況下才會被處理,而不是調用了`deleteLater()`函數的那個循環。例如:
~~~
QObject *object = new QObject;
object->deleteLater();
QDialog dialog;
dialog.exec();
~~~
這段代碼**并不會**造成野指針(注意,`QDialog::exec()`的調用是嵌套在`deleteLater()`調用所在的事件循環之內的)。通過`QEventLoop`進入局部事件循環也是類似的。在 Qt 4.7.3 中,唯一的例外是,在沒有事件循環的情況下直接調用`deleteLater()`函數,那么,之后第一個進入的事件循環會獲取這個事件,然后直接將這個對象刪除。不過這也是合理的,因為 Qt 本來不知道會執行刪除操作的那個“外部的”事件循環,所以第一個事件循環就會直接刪除對象。
- (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(續)