前面一章我們簡單介紹瞭如何使用QThread
實現線程。現在我們開始詳細介紹如何“正確”編寫多線程程序。我們這裏的大部分內容來自於Qt的一篇Wiki文檔,有興趣的童鞋可以去看原文。
在介紹在以前,我們要認識兩個術語:
- 可重入的(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()
槽函數。有關這一機制我們在前面的事件部分曾有闡述,如果不明白這部分機制,請參考前面的章節。
在worker
努力工作的時候,事件循環在幹什麼?或許你已經猜到了答案:什麼都沒做!事件循環發出了鼠標按下的事件,然後等着事件處理函數返回。此時,它一直是阻塞的,直到Worker::doWork()
函數結束。注意,我們使用了“阻塞”一詞,也就是說,所謂阻塞事件循環,意思是沒有事件被派發處理。
在事件就此卡住時,組件也不會更新自身(因爲QPaintEvent
對象還在隊列中),也不會有其它什麼交互發生(還是同樣的原因),定時器也不會超時並且網絡交互會越來越慢直到停止。也就是說,前面我們大費周折分析的各種依賴事件循環的活動都會停止。這時候,需要窗口管理器會檢測到你的應用程序不再處理任何事件,於是告訴用戶你的程序失去響應。這就是爲什麼我們需要快速地處理事件,並且儘可能快地返回事件循環。
現在,重點來了:我們不可能避免業務邏輯中的耗時操作,那麼怎樣做才能既可以執行那些耗時的操作,又不會阻塞事件循環呢?一般會有三種解決方案:第一,我們將任務移到另外的線程(正如我們上一章看到的那樣,不過現在我們暫時略過這部分內容);第二,我們手動強制運行事件循環。想要強制運行事件循環,我們需要在耗時的任務中一遍遍地調用QCoreApplication::processEvents()
函數。QCoreApplication::processEvents()
函數會發出事件隊列中的所有事件,並且立即返回到調用者。仔細想一下,我們在這裏所做的,就是模擬了一個事件循環。
另外一種解決方案我們在前面的章節提到過:使用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() //
第一次調用
QCoreApplication::processEvents() //
手動發出所有事件
[…]
QWidget::event(QEvent * ) //
用戶又點擊了一下按鈕…
Button::mousePressEvent(QMouseEvent *) Button::clicked() //
又發出了信號…
[…]
Worker::doWork() //
遞歸進入了槽函數!
當然,這種情況也有解決的辦法:我們可以在調用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 本來不知道會執行刪除操作的那個“外部的”事件循環,所以第一個事件循環就會直接刪除對象。