1. 程式人生 > >Message Loop 原理及應用

Message Loop 原理及應用

now() ret 有一個 cocoa 等待 導致 current nbsp 多線程通信


此文已由作者王榮濤授權網易雲社區發布。

歡迎訪問網易雲社區,了解更多網易技術產品運營經驗。


Message loop,即消息循環,在不同系統或者機制下叫法也不盡相同,有被叫做event loop,也有被叫做run loop或者其他名字的,它是一種等待和分派消息的編程結構,是經典的消息驅動機制的基礎。為了方便起見,本文對各系統下類似的結構統稱為message loop。

結構

Message loop,顧名思義,首先它是一種循環,這和我們初學C語言時接觸的for、while是同一種結構。

在Windows下它可能是這個樣子的:

MSG msg;BOOL bRet;
...while (bRet = ::GetMessage(&msg, NULL, 0, 0)) {    if (bRet == -1) {        // Handle Error
    } else {
        ::TranslateMessage(&msg);
        ::DispatchMessage(&msg);
    }
}

在iOS下它可能是這個樣子的:

BOOL shouldQuit = NO;
...BOOL ok = YES;
NSRunLoop *loop = [NSRunLoop currentRunLoop];while (ok && !shouldQuit) {
    ok = [loop runMode:NSDefaultRunLoopMode beforeDate:[NSDate distantFuture]];
}

而用libuv實現的I/O消息循環則可能是這樣:

bool should_quit = false;
...
uv_loop_t *loop = ...while (!should_quit) {
    uv_run(loop, UV_RUN_ONCE);
}

在其他系統或機制下,它還有各自獨特的實現,但都大體相似。

事實上,正常運行過程中在接到特殊消息或者指令之前,它就是一個徹底的死循環!同時,這樣的結構也決定了它更多意義上是一種單線程上的設計。也正因為如此,對這種編程結構進行了封裝的系統(比如iOS)也往往不保證或者根本不屑於提及其線程安全性。而多線程共享的消息循環在筆者看來在絕大部分場景下都屬於逆天的設計,本文只討論單線程上的消息循環。

Loop前面有個定語message,進一步表明它要處理的對象,即消息。這裏說的消息是廣義上的消息,它可能是UI消息、通知、I/O事件等等。那麽消息從哪裏來?消息循環又從哪裏提取它們?這在不同系統或機制下有所不同:有來自消息隊列的,有來自輸入源/定時器源的,有來自異步網絡、文件完成操作通知的,還有來自可觀察對象狀態變化的等等。這裏把消息循環提取消息的源統稱為消息源,簡稱源。

消息產生後源不會也無法主動推給消息循環。以Windows消息為例,一條異步窗口消息產生後它會被存放在窗口所屬線程的消息隊列上,如果消息循環不采取任何措施,那麽它將永遠無法被處理。消息循環從消息隊列中去抽取,它才能被取出並分派。這種從消息隊列中抽取消息的機制,我們叫做消息泵。

生命期

Message loop的生命期始於線程執行過程中第一次進入該循環的循環體,終於循環被break或者線程被強行終止那一刻,而兩者之間便是運行期。

運行期內,消息泵不停嘗試從源那裏抽取消息,如果源內消息非空,那麽消息將被立即取出,接著被分派處理。如果源內沒有消息,消息循環便進入空載(idling)階段。就像水池中沒有水時抽水泵開著是浪費電能一樣,如果消息泵在空載時也無休止地工作也將浪費幾乎所有的CPU資源。為了解決這個問題,需要消息泵在空載時能夠自我阻塞,這種特征往往需要源來提供。源的另一個特點是在新消息到達之後將阻塞中的消息泵(準確說是消息循環所在線程)喚醒,使之恢復工作。以上面的例子來說,GetMessage、NSRunLoop.runMode:beforeDate:以及uv_run操作的對象都具備這兩個特點。

新消息的添加可能來自於本線程也可能來自於其他線程,甚至包括其他進程中的線程。另外很多系統提供了對待處理消息的撤銷或者移除操作,比如Windows下的PeekMessage、CancelIo分別可以移除待處理的UI消息和I/O操作,iOS下的NSRunLoop.cancelPerformSelectorsWithTarget:族方法則可以撤銷待處理的selector。

結束消息循環的過程和結束一個普通的for、while循環大致相同,就是改變循環控制表達式的值使之不滿足繼續循環的條件。不同的地方在於,普通循環往往是自發的,而消息循環可能來自外部的需求,然後通過某種方式通知該消息循環讓其自我退出。另一種結束消息循環的方式是強制中止其所屬線程的執行,當然了,這是極不推薦的。

嵌套

Message loop是可以嵌套(nested)的,簡而言之就是Loop1上在處理一個任務的過程中又起了一個另一個Loop2。請看以下場景:

void RunLoop() {    while (GetMessage(&msg)) {
        ...
        ProcessMessage(&msg);
        ...
    }
}void Start() {
    RunLoop(); // 進入Loop1}void ProcessMessage(MSG *msg) {
    ...    if (msg->should_do_foo_bar) {
        Foo();
        RunLoop(); // 進入Loop2,嵌套!
        Bar();
    }
    ...
}

嵌套的一個典型案例就是模態對話框。在模態對話框返回之前此後的語句不會被執行,比如上例中Bar在RunLoop返回之前不會被執行,因為Loop1在Loop2啟動後就處於阻塞狀態了,這就引出了嵌套消息循環的一個特點:任何時刻有且只有一個Loop是活動的,其余都是被阻塞的。嵌套消息循環的另一個特點是它們同屬於一個線程,反過來說,非同線程的message loop無法形成嵌套。

嵌套的一個比較明顯的坑:如果Bar運行需要資源R,而R在Loop2生命期內被釋放了,那麽等Loop2生命期結束後Loop1恢復執行,第一個調用的就是Bar,此時R已經不存在了,Bar的代碼如果缺乏足夠的保護就有可能會引起crash!

多線程通信

Message loop讓線程間通信變得足夠靈活。

技術分享圖片

如上圖,運行消息循環的兩個線程Thread 1和Thread 2之間通過向對方的消息隊列中投遞消息來進行通信,這個過程是完全異步的。

結合前文提到的消息循環嵌套技術,多線程通信時,通信發起線程可以在不阻塞本線程消息處理的前提下等待對方回應後再進行後續操作。以上文中的Foo和Bar為例,如果Foo異步請求資源,Bar處理接收到的資源,Loop 2等到資源被接收後立即結束,那麽它們三者宏觀上看起來像是一次同步資源請求和處理操作,而且在此期間Thread 1和Thread 2消息處理順暢!這非常奇妙,在很多情況下比阻塞式的傻等有用多了。

然而,消息投遞過程本身是跨線程的操作,對於使用C++這樣的Native語言開發的場景,這意味著樸素地操作別的線程的消息隊列本身就存在隱患,所以一般需要對消息隊列進行鎖保護。此外,線程間一般推薦只持有對方消息隊列的弱引用,否則很容易陷入循環引用或者導致野指針範圍——試想如果Thread 2先退出,其消息隊列實體也被銷毀,此後如果Thread 1嘗試通過Thread 2消息隊列的裸指針向其投遞消息勢必造成災難。

多線程之間通信比較難以處理的是消息的撤銷和資源的管理,但是這個不在本文的討論範圍之內,如果有時間,筆者將在未來撰文討論這個問題。

附加機制

至此,本文描述的消息循環僅僅在處理消息本身,其實我們在消息循環中還可以加入一些十分有用的機制,這裏介紹其中最常用的兩種。

空閑任務(Idle tasks)是在消息循環處於空載狀態時被處理的任務。消息循環空載往往意味著沒有特別緊要的消息需要處理,這個時候是處理空閑任務的絕佳時機,比如發送一些後臺統計數據。以基於libuv的I/O消息循環為例,對其稍加改動便可加入這種機制:

class UVMessageLoop {public:
    ...private:    bool should_quit_;    bool message_processed_;
    uv_loop_t *loop_;
};void UVMessageLoop::OnUVNotification(uv_poll_t *req, int status, int events) {
    UVMessageLoop *loop = static_cast<UVMessageLoop *>(req->data);
    ...
    loop->message_processed_ = true;
}void UVMessageLoop::Run() {    for (;;) {
        uv_run(loop, UV_RUN_ONCE);        if (should_quit_)            break;        if (message_process_) {            // 剛剛處理了一條消息
            continue;
        }        // 沒有消息,處理idle task
        bool has_idle_task = DoIdleTasks();        if (should_quit_)            break;        if (has_idle_task) {            continue;
        }        // idle task都沒有,再抽取一次消息,沒有就自我阻塞
        uv_run(loop, UV_RUN_NOWAIT);
    }
}

註意上例中兩次uv_run調用的第二個參數是不同的,UV_RUN_NOWAIT用於嘗試從源抽取並處理一次I/O事件但是若沒有也立即返回;而UV_RUN_ONCE則是在沒有事件的時候被阻塞直到新事件到達。需要註意的是,在uv_run處理事件的時候最終會同步調用到UVMessageLoop::OnUVNotification,這樣其返回後可以通過檢查message_processed_來知道是否有消息被處理了。

遞延任務(Deferred tasks)是晚於投遞時間被執行的任務,比如在播放動畫時使用它可以在幀時間到達時才真正渲染某個幀。繼續以基於libuv的I/O消息循環為例,作如下改動後可以加入這種機制:

class UVMessageLoop {public:
    ...private:    bool should_quit_;    bool message_processed_;
    TimeTicks deferred_task_time_;
    uv_loop_t *loop_;
    uv_timer_t *timer_;
};void UVMessageLoop::OnUVNotification(uv_poll_t *req, int status, int events) {
    UVMessageLoop *loop = static_cast<UVMessageLoop *>(req->data);
    ...
    loop->message_processed_ = true;
}void UVMessageLoop::OnUVTimer(uv_timer_t* handle, int status) {
    ...
}void UVMessageLoop::Run() {    for (;;) {
        uv_run(loop, UV_RUN_ONCE);        if (should_quit_)            break;        if (message_process_) {            // 剛剛處理了一條消息
            continue;
        }        // 沒有消息,處理遞延任務,同時獲取下一個遞延任務的時間
        bool has_deferred_task = DoDeferredTasks(&deferred_task_time_);        if (should_quit_)            break;        if (has_deferred_task) {            continue;
        }        // 也沒有遞延任務,處理idle task
        bool has_idle_task = DoIdleTasks();        if (should_quit_)            break;        if (has_idle_task) {            continue;
        }        // 沒有idle task
        if (delayed_task_time_.is_null()) {            // 也沒有deferred task,再抽取一次消息,沒有就自我阻塞
            uv_run(loop_, UV_RUN_ONCE);
        } else {
            TimeDelta delay = delayed_task_time_ - TimeTicks::Now();            if (delay > TimeDelta()) {                // 設置定時器,如果在定時器到期前還沒有其他事件到達而被解除阻塞,
                // 那麽uv_run將因為定時到期事件而被解除阻塞
                uv_timer_start(timer_, OnUVTimer, delay.ToMilliseconds(), 0);
                uv_run(loop_, UV_RUN_ONCE);
                uv_timer_stop(timer_);
            } else {                // 有遞延任務未及時處理,進入下一輪後處理
                delayed_task_time_ = TimeTicks();
            }
        }        if (should_quit_)            break;
    }
}

由於遞延任務一般優先級高於空閑任務,所以我們先於空閑任務處理它們。另外deferred_task_time_記錄了下一個遞延任務的單調遞增時間(比如當前線程的clock值),當沒有I/O事件需要處理且也沒有Idle任務需要處理時,如果有尚未到期的遞延任務,那麽需要在源上開啟一個定時器在遞延任務到期後解除消息泵的阻塞。因此,要支持遞延任務的源必須具備第三個特點,那就是支持定時喚醒。

參考資料:

http://docs.libuv.org/en/latest/loop.html

https://msdn.microsoft.com/en-us/library/windows/desktop/ms644936(v=vs.85).aspx.aspx)

https://developer.apple.com/library/mac/documentation/Cocoa/Reference/Foundation/Classes/NSRunLoop_Class/

https://docs.google.com/document/d/1_pJUHO3f3VyRSQjEhKVvUU7NzCyuTCQshZvbWeQiCXU/



網易雲免費體驗館,0成本體驗20+款雲產品!

更多網易技術、產品、運營經驗分享請點擊

相關文章:
【推薦】 中秋福利|10本技術圖書(編程語言、數據分析等)免費送

Message Loop 原理及應用