Message Loop 原理及应用
此文已由作者王荣涛授权网易云社区发布。
欢迎访问网易云社区,了解更多网易技术产品运营经验。
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://docs.google.com/document/d/1_pJUHO3f3VyRSQjEhKVvUU7NzCyuTCQshZvbWeQiCXU/
网易云免费体验馆,0成本体验20+款云产品!
更多网易技术、产品、运营经验分享请点击。
相关文章:
【推荐】 中秋福利|10本技术图书(编程语言、数据分析等)免费送