轉載豆子Qt 學習之路 2(72):線程和事件循環

前面一章我們簡單介紹瞭如何使用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()函數來控制事件的對象。

事件可以由程序生成,也可以在程序外部生成。例如:

  • QKeyEventQMouseEvent對象表示鍵盤或鼠標的交互,通常由系統的窗口管理器產生;
  • 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 通過返回事件循環來實現這些調用。
  • 網絡:所有低級網絡類(QTcpSocketQUdpSocket以及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 本來不知道會執行刪除操作的那個“外部的”事件循環,所以第一個事件循環就會直接刪除對象。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章