深入理解Flutter/Dart事件机制

3,485 阅读18分钟

前言

在前文《Flutter/Dart中的异步》里,我们知道了Flutter/Dart程序是事件驱动的,Dart代码都是以Isolate的形式存在。每个Isolate内部都有一个事件循环,事件循环

Dart代码的运行就是在不停的在处理一个又一个的事件。Isolate之间是不能直接互相访问的,它们之间需要通过来端口(Port)互相通讯。理解这个事件机制是理解Flutter/Dart运行的基础。这个事件机制就如同人体的神经系统一样,可以使程序的各个部分能协同运转。也能为我们回答以下这些问题:

  • Isolate之间如何通过端口(Port)互相通讯?
  • 定时器Timer以及微任务是如何工作的?
  • 程序的I/O是如何进行的?
  • Isolate中做网络请求为什么不会阻塞?
  • Flutter对Dart的事件机制做了哪些改造?

要得到以上问题的答案就需要在了解Dart语言,Flutter框架的基础上再深入学习Dart虚拟机以及Flutter引擎(Engine)部分的源码。本文余下部分会在尽量不贴源码的基础上给大家介绍事件机制的底层实现。

事件机制

读过Dart虚拟机相关文章的大家一定都看到过下面这张图。 这是Google工程师Vyacheslav Egorov写的一篇介绍Dart虚拟机的博客《 Introduction to Dart VM》里的一张图。从这张图和博客里的说明我们可以知道Dart代码都是运行的在Isolate中的,从底层看执行是在某一个Mutator Thread,也就是在某个具体线程中。但是Isolate与系统线程在整个程序生命周期内并不是一一绑定的。一个Isolate现在运行在线程池的某个线程中,过一会可能会运行在线程池的另一个线程中。同样的,对于一个线程池的线程来说,可能这会儿在运行一个Isolate,过会儿会运行另一个Isolate。但是有一点可以确定就是在某一时刻,一个Isolate只会运行在一个系统线程中。从这种对应关系可以看出,Isolate更像是运行在线程池中的一个个任务。

Isolate的消息处理

那么Isolate又是如何在线程中运行呢?从我们对于事件驱动程序架构的了解,就能预计这个线程中必然要运行的是消息循环。有消息循环那就必然会有消息队列,同样的还要对外开放接收消息的端口,这样的话Isolate就可以用下图来表示:

Screen Shot 2021-09-22 at 10.44.35 PM.png

和一般情况不同的是,Isolate的消息循环并不是一个死循环,而只有一个消息处理的功能。当有外部消息到来的时候,消息首先会被插入消息队列MessageQueue。如果此时Isolate并没有在运行的话,虚拟机会将消息处理器以任务的形式交给线程池,线程池会视情况为其分配一个线程,然后在分配的线程上开始执行任务处理器,也就是从队列里取一个消息,处理一个消息,直到队列为空。如果消息都处理完了,那么线程的任务也就执行完了,这个线程也就空闲出来了,线程池有可能调度新的任务给它执行,而这个新的任务有可能会是另一个Isolate的消息处理器。

可以看出Dart虚拟机在Isolate运行这一块的处理是相当灵活的,Isolate并不会长期占用一个线程,而是大家共用一个线程池,谁有消息需要处理就获得线程资源,没有的话就不占用线程资源。

消息队列

Isolate的消息处理器中存在着两个消息队列,一个队列是普通消息队列,另一个队列叫OOB消息队列,OOB是"out of band"缩写,翻译为带外消息,OOB消息用来传送一些控制类消息,例如从当前Isolate生成(spawn)一个新的Isolate。我们可以在当前 Isolate发送OOB消息给新Isolate,从而控制新Isolate。比如,暂停(pause),恢复(resume),终止(kill)等。

OOB消息的优先级是高于普通消息的,消息处理器在从消息队列中获取消息的时候会优先从OOB消息队列获取消息,当OOB消息队列为空之后,才会从普通消息队列中去获取消息。

消息

对于需要被传过来传过去的消息来说,最重要的就是这个消息的目的地地址。在Dart中,这个地址就是Port。每个消息都会绑定一个目标端口,只有这样,这个消息才会被正确的投递到相应Isolate的消息处理器。如果这个端口是个无效端口的话,消息会被丢弃掉。

端口以及PortMap

从以上表述可知,Dart消息机制用来做寻址的就是端口Port。每个Isolate都会有一个消息处理器,同时Isolate也会根据需要对外暴露多个端口。每个端口都会和一个消息处理器绑定。

显然在Dart虚拟机中会存在多种需要收发消息的情况,Isolate之间需要收发消息,Isolate需要接收I/O消息,以及定时器Timer消息等。这些消息往往要跨越不同的线程。Android采用的是"Looper-Handler"机制,而Dart虚拟机则采用一种更为直接的办法。在虚拟机内部存在着一个全局唯一的PortMap来同一管理各个端口的生命周期以及消息的传递。这样的话每个线程都可以访问到PortMap,用来传递消息自然也不存在障碍了。

PortMap内部维护者一个保存着所有端口信息的哈希表。这个哈希表的每个元素都有一个端口号和对应的消息处理器。这样的话只需要通过端口号来查这个表,就能得到消息处理器了,能找到消息处理器,自然也就可以将消息在消息处理器里入队然后让消息处理器去处理。

Screen Shot 2021-09-23 at 2.43.10 PM.png

PortMap同时也管理者所有端口的生命周期,每个端口的创建和关闭都需要通过PortMap来操作。以Isolate为例,当我们在Isolate中新建一个ReceivePort的时候,这个调用最后会来到PortMap这里。PortMap会生成一个端口号,把这个端口号与当前Isolate的消息处理器(Message Handler)绑定然后保存在PortMap的哈希表里面。

从Dart虚拟机的实现来看,PortMap在虚拟机初始化的时候就会初始化。其内部会有一个随机数生成器,每当要创建新端口的时候就会随机生成一个端口号。

关闭端口的时候会将端口号对应的元素从哈希表中删除。

当线程需要向外发送消息的时候,会调用PortMap::PostMessage()根据端口号来查询哈希表,找到端口对应的消息处理器之后就可以将消息入队进行处理了。消息传递过程如下图所示

Screen Shot 2021-09-23 at 2.38.00 AM.pngPortMap使用全局哈希表来存储端口信息我们可以想到,端口在不再需要的时候要关闭,进一步的,Isolate在不再需要的时候也要及时杀掉,否则可能会使资源不能及时释放,从而造成“泄漏”。

消息分发

Dart的消息分发是分为两个层面的,一个是在Native层的消息处理器,其他线程或者Isolate发过来的消息都会首先汇聚到这里,Native层的消息处理器在处理消息的时候再进入Dart层消息处理器做进一步的分发。如果把整个Isolate所在的线程比作一个小区,Dart层每个监听的每个ReceivePort比作小区住户的话,那么Native层的消息处理器就是小区大门,消息要入户还需要在Dart层做进一步分发。

Screen Shot 2021-09-23 at 3.01.58 PM.png

在Dart层,每个Isolate会有一个自己私有的_portMap,里面存储的也是ReceivePort端口号和对应的handler。我们知道ReceivePort实现了Stream接口。对应的handler在收到消息以后会将消息数据写入ReceivePort,这样监听这个Stream的回调就能对消息数据做处理了,Dart层消息处理代码如下:

@pragma("vm:entry-point", "call")

static void _handleMessage(Function handler, var message) {

handler(message);

_runPendingImmediateCallback();

}

消息处理做了两件事,首先是处理从端口过来的消息handler(message)。然后还有一个 _runPendingImmediateCallback();。这个函数调用会处理所有的微任务。这也就是前言里面那张事件循环图的由来。

从以上描述我们也可以看出,Port消息机制是单向的,这也是为什么我们通常在spawn一个新的Isolate的时候会创建一个新的ReceivePort,然后把相应的SendPort交给子Isolate。这样就建立起了子Isolate到父Isolate的通道,如果需要双向通信的话,子Isolate也要创建自己的ReceivePort,把对应的SendPort通过上一个通道传给父Isolate。听起来比较绕,但是明白了消息机制以后理解起来就容易多了。

Timer机制

定时器Timer是另一个重要的事件来源。Dart虚拟使用EventHandler来管理定时器资源。要使用定时功能,就必须要调用系统底层资源,为此,Dart虚拟机在初始化的时候会初始化EventHandlerEventHandler会专门开一个线程来提供定时器功能。这个线程被命名为"dart:io EventHandler"。由于对底层系统的依赖,不同系统的实现也有所不同,以Android为例,定时器功能在底层依赖的是epoll机制。

显然Isolate要使用定时器功能,就需要和EventHandler相互通讯。Isolate需要通知EventHandler来设置/取消定时器,而当定时器到点的时候,EventHandler要将这一消息发送给Isolate

先来说从IsolateEventHandler,根据前述的消息机制,似乎EventHandler需要先在PortMap里开一个端口,然后Isolate通过这个端口给EventHandler发消息。然而这是不必要的,因为EventHandler是全局唯一的。要给EventHandler发消息是不需要经过PortMap的。直接调用EventHandler提供的EventHandler_SendData方法就可以了。

但是反过来从EventHandlerIsolate就需要端口了,否则EventHandler不知道要给谁发消息,这个端口号需要在上一步IsolateEventHandler消息里以参数的形式送过来。

Screen Shot 2021-09-23 at 4.28.53 PM.png

在Dart层,Isolate的所有定时器都由_Timer管理。在存在有效定时器的时候,_Timer会开放一个ReceivePort以便接收定时器到时消息。

我们知道定时器的使用分为两类,一类是带延时的,另一类是不带延时,或者说延时为0的定时器。对这两类定时器_Timer也采用了不同的管理策略。

  • 如果新建的定时器是无延时的,_Timer会将其插入一个叫ZeroTimer的链表。

  • 如果新建的定时器是有延时的,_Timer会将其插入一个叫_TimerHeap的二叉堆。堆顶就是最近到点的计时器。

_Timer对这两类计时器的处理也是不同的:

  • 无延时定时器在被插入ZeroTimer链表后会通过_sendPort给自己发一个_ZERO_EVENT消息。

  • 有延时的定时器在被插入_TimerHeap二叉堆后,会检查当前定时器是不是最近要到点的,如果是的话,就会给EventHandler发送消息,消息里会带上sendPort和最近要唤醒的时间。到点后EventHandler会发回来_TIMEOUT_EVENT消息。

_Timer自带消息处理器,而不使用前述的通用消息处理器,在消息到来之后,消息处理器首先要找出当前需要处理的定时器列表pendingTimers:

  • 收到_ZERO_EVENT,先取二叉堆中所有比当前无延时定时器还早超时的定时器加入列表,最后将当前无延时定时器也加入列表。

  • 收到_TIMEOUT_EVENT,如果存在无延时定时器,则会将二叉堆中所有比当前无延时定时器还早超时的定时器加入列表;如果不存在无延时定时器,则会将二叉堆中所有比当前系统时间还早超时的定时器加入列表。

拿到需要处理的定时器列表pendingTimers后,消息处理器会挨个调用每个计时器的回调函数并更新其状态,如果有周期定时器还要再重新入堆。

最后,为了满足Dart事件循环的设计要求,每完成一个定时器的回调之后都要调用_runPendingImmediateCallback()来清空微任务队列。

从上述定时器工作过程我们也能看到,只有有延时的定时器才会通过EventHandler去在底层做设置,无延时的定时器完全是在Dart层由_Timer自行处理。而有延时的定时器也只会将最近将要超时的一个定时器发送给EventHandler管理,其余的有更长延时的定时器由_Timer自行在二叉堆中管理,这样设计也是为了节省系统资源。

I/O机制

系统I/O同样也是重要的事件来源,Dart的I/O机制自身细节是比较复杂的,本小结只会从消息传递的角度对I/O机制做一些阐述,具体的文件,目录,http,socket等I/O方式实现细节还需要仔细学习源码。

虚拟机在Dart层提供了_IOService来统一处理所有I/O请求。Dart层所有I/O操作,如文件的读写,网络请求等都会归集到_IOService从而转至Native层进行处理。_IOService给每种I/O操作都定义了一个编号,例如打开文件操作被定义为static const int fileOpen = 5,此类中一共定义了43种I/O操作,具体可查看_IOService源码。

所有I/O操作都是异步返回的,也就是说发起I/O操作的Isolate和底层具体执行的Native代码之间是通过消息系统来互相沟通的。下面我们就来说说他们之间的通信通道是怎么建立起来的。

在接收到上层来的I/O调用请求时,_IOService首先确保自己先完成初始化。这个初始化的主要是确保自己有一个ReceivePort,没有就创建一个。这个ReceivePort就用来接收所有的I/O消息。

好了,现在有了本端的接收端口,那接下来就是对方的Native端接收端口了。这个Native端接收端口是由_IOService通过调用IOService_NewServicePort在Native层去创建,同样的最终也要由PortMap做创建的工作。而我们知道在PortMap内部一个端口必须要绑定一个MessageHandler。这个底层端口绑定的并不是Isolate的消息处理器,而是专为处理Native消息服务的NativeMessageHandlerNativeMessageHandlerIsolateMessageHandler都是继承自MessageHandler。所以在Native层面其消息处理也是在线程池中进行的。也就是说上述那些具体的I/O操作,例如打开文件,是在线程池里完成的。

Native端的ReceivePort,或者我们可以称之为ServicePort创建完成之后,其对应的SendPort会被返回给Dart层。

所以现在的状态是Dart层和Native层都有了ReceivePort,并且Dart层也拿到了Native层的SendPort。接下来要发起I/O请求就比较简单了,只需要调用SendPort.send将请求I/O操作的参数(比如上面说的fileOpen = 5),相应的参数(比如文件路径)和Dart层sendport(I/O操作完成后Native需要这个sendport来通知Dart层操作的结果。)组合成消息送给NativeMessageHandlerNativeMessageHandler接收到消息以后会在线程池中处理这个消息,处理的时候会检索请求I/O操作的参数,然后找到对应的底层I/O完成操作。完成之后再通过sendport给Dart层发消息告知操作结果。这样一整个I/O操作流程就完整了。如图所示:

Screen Shot 2021-09-24 at 12.17.47 AM.png

因为Dart层接收消息走的还是原来的路径,所以I/O操作也是满足Dart事件循环的准则的。

小结

至此,对Dart虚拟机的事件机制就介绍的差不多了。了解了整个消息系统的运行机理,相信大家对Dart虚拟机的结构不会再感到陌生了。对于前言里的那张事件驱动示意图会有更加深刻的了解。

Flutter是基于Dart虚拟机,但上述消息机制并不能满足Flutter的需求,所以Flutter对 Dart虚拟机的消息机制做了一些改造。下面的章节就简单介绍一下Flutter对Dart虚拟机的定制。

Flutter的定制

我们都知道Flutter在启动的时候会创建三个线程,分别是UI,GPU和IO,再加上原生的Platform线程,这四个线程互相协调,共同撑起了Flutter运行的基础。其中UI线程会运行RootIsolate。在RootIsolate中会运行Flutter框架,也就是我之前的Flutter框架分析系列文章里所说的渲染流水线。RootIsolate如此重要,显然不能像普通的Isolate那样把它的消息处理器扔给线程池去跑。而是指定在UI线程中运行RootIsolateMessageHandler

消息处理定制

而这种指定是如何做到的呢?那就是RootIsolate在启动的时候有两个地方和普通的Isolate不一样之处。

一个是在RootIsolate初始化的时候,会把UITaskRunner设置给RootIsolate,最终会给RootIsolateMessageHandler设置message_notify_callback_。 如此设置之后就会将RootIsolateMessageHandler引导在UI线程运行。

另一个就是要禁止RootIsolateMessageHandler在线程池上运行。这又是如何做到的呢?普通Isolate在运行Dart代码之前需要调用MessageHandler.run(),这个函数调用会给MessageHandler设置线程池。但RootIsolate没有调用这个函数的,而是跳过这一步直接开始运行Dart代码。所以RootIsolateMessageHandler是没有线程池的,它的消息处理器只能运行在UI线程上。

// 这个函数调用会设置线程池
void MessageHandler::Run(ThreadPool* pool, ...) {
...
pool_ = pool;
...
const bool launched_successfully = pool_->Run<MessageHandlerTask>(this);

}

决定消息处理器运行在哪个线程的奥秘就在于函数MessageHandler.PostMessage():

void MessageHandler::PostMessage(std::unique_ptr<Message> message,bool before_events) {
    // 正常消息入队
    if (message->IsOOB()) {
        oob_queue_->Enqueue(std::move(message), before_events);
    } else {
        queue_->Enqueue(std::move(message), before_events);
    }

    ...
    // 普通ioslate由于线程池不为空,会进入下方语句块,在线程池上运行消息处理器。
    // `RootIsolate`由于没有线程池,会跳过。
    if (pool_ != nullptr && !task_running_) {
        const bool launched_successfully = pool_->Run<MessageHandlerTask>(this);
    }
    //对于`RootIsolate`通过。调用以下函数,最终会是其消息处理器运行在UI线程。
    MessageNotify(saved_priority);
}

Flutter通过上述两处定制,就使RootIsolateMessageHandler运行在UI线程上了。这里我们需要明确一点,整个消息处理的逻辑还是没有变的,变化的只是运行的线程。

微任务定制

Flutter对另外一处消息机制的定制是对微任务的处理。原生Isolate的微任务调度以及执行都是在Dart层。切入点是在Dart层消息处理函数处理完一个消息之后执行_runPendingImmediateCallback()

Flutter在初始化RootIsolate会把Dart层调度微任务的函数设置成Native层的ScheduleMicrotask。如此一来,微任务执行的触发也被挪到了Native层。当UIDartState::FlushMicrotasksNow被调用以后就会开始微任务执行。

在Flutter中触发微任务执行的时机有两处。一处是每当UITaskRunner执行完一个任务以后会触发微任务。从上面对Flutter消息机制的分析我们了解到RootIsolate的消息处理器变成了由UITaskRunner运行的一个任务。而且消息处理器每次只会处理一个正常消息,这样的话依然满足Dart事件循环的标准。

另一处是在engine回调_beginFrame之后和回调_drawFrame之前。在这两个回调之间会触发微任务执行。关于这两个回调请参考《# Flutter框架分析(一)-- 总览和Window》

总结

本文从虚拟机底层角度介绍了Dart事件机制的运行原理和定时器事件,I/O事件的实现以及Flutter对原生Dart事件机制的定制。事件机制就如同循环系统之于动物,道路系统之于城市。了解了事件机制之后,再去看Dart/Flutter内部的各个功能模块就会如庖丁解牛一样轻松愉快。 (全文完)