揭秘Flutter/Dart的异步机制

前言

在我的上一篇文章《深入理解Flutter/Dart事件机制》里介绍了Flutter/Dart事件机制的底层原理,相信大家读完以后会对Flutter/Dart事件机制有一个比较深入的了解。这篇文章呢,就是在前一篇文章的基础之上,跟大家一起对Dart异步机制的本质做一些探讨。

再谈事件循环

首先呢,还是上这张已经包浆的事件循环示意图: 事件循环示意图

这张图是对事件循环模型的一个抽象,在另一篇文章《Flutter/Dart中的异步》里我曾经对这张图片做了一些介绍,我们都知道循环里存在两个队列,循环的过程是程序开始先执行main函数,main函数执行完毕以后,清空微任务队列,然后再处理一个event。如此循环直到程序退出。

那么事件队列里面放着什么?微任务队列里面又放着什么呢?我们之前的了解可能会说事件队列里放着外部事件,例如定时器事件,IO事件等等,微任务队列放着微任务,调用scheduleMicrotask函数就可以给微任务队列添加微任务。

上述理解是对的,但是又总觉得没有抓住本质,为什么定时器,IO会被归类为事件,它们到底和微任务有什么区别呢?

其实答案就在《深入理解Flutter/Dart事件机制》里,总结一句话就是所有通过Port传递的消息都是事件(event)。我们在实际写Dart代码的时候接触到Port可能只是在需要Isolate时会创建ReceivePort,然后把相应的SendPort传递给另一个Isolate,这样它们之间可以通信。这里的ReceivePort是对更底层的Port-MessageHandler机制的一个封装。而我们在做网络请求,文件读写,设置定时器的时候,Dart已经帮我们把Port创建好了,对我们是不可见的。Port-MessageHandler机制是是Dart/Flutter程序能够运行的根基,来自Port的消息是真正驱动程序运行的动力。

一个Isolate当然也可以自己给自己发event,例如下面的代码:

void main() {
  print('main E');
  var receivePort = ReceivePort();
  void onMsg(dynamic msg) {
    print('$msg');
  }
  receivePort.listen(onMsg);
  receivePort.sendPort.send('hello from port');
  print('main X');
}
复制代码

输出如下图所示:

Screen Shot 2022-06-06 at 12.12.16 AM.png

可以看到,"hello from port"是在main函数执行完成以后才打印出来的,可见这是一个异步事件。上述代码存在一个问题,就是最后一条消息输出以后程序并没有退出,这是因为我们没有关闭ReceivePort导致的,所以需要加一行代码在适当的时候关闭端口,改造后的代码如下:

void main() {
  print('main E');
  var receivePort = ReceivePort();
  void onMsg(dynamic msg) {
    print('$msg');
    // 在使用完成后要关闭端口
    receivePort.close();
  }
  receivePort.listen(onMsg);
  receivePort.sendPort.send('hello from port');
  print('main X');
}
复制代码

运行结果:

Screen Shot 2022-06-06 at 12.18.01 AM.png 从图中可见这时程序就可以正常退出了。

当然我们一般不会这么使用端口,同样的功能完全可以由Timer.run来实现。其底层原理是一样的。

那么微任务显然不是通过端口来调度的,我们知道scheduleMicrotask可以添加微任务,但似乎并没有明确的用途,实践中也很少直接去调用这个函数。事实上微任务在Dart/Flutter中是被大量使用着的,只不过它被包装成了Future而已,所以我们基本上看不到直接调度微任务的代码。注意这里说的不是Future.microtask,而是一般意义上的Future

Future

Future是啥?Future就是Callback回调的另一种形式,它使用链表的形式将一个个通过thenawait添加的回调链接起来。每次调用then或者await都是会返回一个新Future,为什么要返回新Future呢?直接只用一个不行吗?因为链式回调一般来说会返回不同类型的Future的,比如你要通过学生的学号返回名字,那么源Future的类型就是Future<int>,通过then添加的回调返回的则是Future<String>

了解Future最关键的就是搞清楚源Future是谁。由谁来改变它的状态。我们在学习Future的时候遇到的第一个例子

Future(() => print('hello'));
复制代码

这里它就是源头。而实践中遇到的多数情况是通用各种库函数来得到一个Future。比如常见的网络请求:

Future<Response> response = http.get(Uri.parse('https://...'));
复制代码

http.get函数会返回给我们一个代表网络响应的Future,该Future创建自请求函数内部,但是请注意,这个Future并不代表它就是网络请求这件事的Future。它只是某更加底层的真正的源Future回调链表上的最末位的回调Future暴露给开发者而已。从最底层的网络响应事件到最上层的Response。这中间会经过很多次转换,也就是说会用很多个Future被串联起来。

最后我们在来说说Future怎么把状态变为完成(Complete)。两种方式:同步( _complete)和异步(_asyncComplete),区别就是异步完成的时候会将完成这一操作调度到微任务队列去执行

void _asyncCompleteWithValue(T value) {
  _setPendingComplete();
  // 看到了熟悉的scheduleMicrotask
  _zone.scheduleMicrotask(() {
    _completeWithValue(value);
  });
}
复制代码

而在Dart各种库中Future最常见的完成方式就是使用Completer.complete。其默认实现就是用异步完成。所以我们虽然没见到多少scheduleMicrotask调用,但是Future却见的太多了。那么结论就是微任务队列里放着的,大部分是各种Future回调。

异步机制揭秘

有了上述知识储备之后,接下来我们就可以去看看异步机制的套路了,为了说明这个套路,让我们先来看一下那个Future的默认构造函数:

factory Future(FutureOr<T> computation()) {
   //新建一个_Future内部类
  _Future<T> result = new _Future<T>();
  Timer.run(() {
    try {
      //在异步事件中将其状态变为完成
      result._complete(computation());
    } catch (e, s) {
      _completeWithErrorCallback(result, e, s);
    }
  });
  return result;
}
复制代码

可以看出,其实这个套路很简单,就是在当前事件中新建_Future,然后在异步事件到来的时候将其状态改为完成就行了。

这里需要说明的是,此处没有使用Completer,而是在异步事件中直接调用同步完成函数_complete。也就是说,这个Future它就是源,而且它的回调不会被调度到微任务队列。这和以下我们要讲的套路有所区别,但是不影响我们理解上面所说的套路。

这个构造函数响应的是定时器事件,也就是说这里我们已经了解到了Timer事件中使用Future的套路。

(另外,这里说句题外话,这里虽然使用Timer做了异步操作,但是终归还是会运行在本Isolate内,所以不要在此做计算密集型耗时操作,此类操作一定要另开Isolate运行。这个建议同样适用于scheduleMicrotask。)

回到主题,说它是套路,就是说涉及到异步的地方基本上八九不离十都是这么搞的,那么事实如何呢?接下来我就贴一些涉及各种事件的比较底层的代码来看看是不是这样的,所谓比较底层就是说尽量接近源Future,尽量接近Port

I/O是另一大事件来源,比如文件相关的操作,我们来看看代码:

@patch

class _IOService {

...
static RawReceivePort? _receivePort;

...
static HashMap<int, Completer> _messageMap = new HashMap<int, Completer>();


@patch

static Future _dispatch(int request, List data) {
   ...
   _ensureInitialize();

   final Completer completer = new Completer();

   _messageMap[id] = completer;

   ...

   servicePort.send(<dynamic>[id, _replyToPort, request, data]);

   return completer.future;

}

static void _ensureInitialize() {

    if (_receivePort == null) {

        _receivePort = new RawReceivePort(null, 'IO Service');

        _receivePort!.handler = (data) {
              ...
            _messageMap.remove(data[0])!.complete(data[1]);
              ...
        };
    }

}
复制代码

去掉一些无关代码之后,套路就呼之欲出了。函数_dispatch在做I/O请求的时候会被调用,其返回的类型我们看到了熟悉的Future。这个Future哪来的呢?是在这里新建的一个Completer,然后返回的是completer.future。它就是个源Future。这个Completer同时也会被放在一个哈希表里面。那么这个Completer什么时候会complete呢?接着看_ensureInitialize函数。首先看到了RawReceivePort,可见已经是相当的底层了。然后它的handler,也就是事件到来以后的响应函数在做什么?从哈希表里面取出那个Completer然后调用complete。从上一小结那里我们知道,默认的Completer是异步完成的,所以这里会将completer.future的回调链调度到微任务队列,接下来运行到微任务的时候就像多米诺骨牌一样从源Future开始连锁反应。

下一个套路的应用之处就是Flutter的Platfrom-Channel,以native方法调用为例,最终会调用到下面这个函数

@override
Future<ByteData?> send(String channel, ByteData? message) {
  final Completer<ByteData?> completer = Completer<ByteData?>();
  
  ui.PlatformDispatcher.instance.sendPlatformMessage(channel, message, (ByteData? reply) {
    try {
      completer.complete(reply);
    } catch (exception, stack) {
       ...
    }
  });
  return completer.future;
}
复制代码

代码就不详细解释了,是不是一样的套路?

最后呢,我们说说Isolate之间通信使用这个套路的例子。这里就以我们最常用的compute函数为例:

Future<R> compute<Q, R>(isolates.ComputeCallback<Q, R> callback, Q message, { String? debugLabel }) async {
  final ReceivePort resultPort = ReceivePort();
 
  final Completer<R> result = Completer<R>();
  
  resultPort.listen((dynamic resultData) {
      result.complete(resultData as R);
  });
  
  return result.future;
}
复制代码

还是一样的套路吧。

总而言之,只要掌握了Dart异步机制的套路,以后当你需要自己写一些有异步操作的代码时也会心里有数了。

重新认识事件循环

至此我们再回头看最开始那张事件循环图,应该有更深层次的理解了。我们之前的认知是main执行完毕之后循环开始,首先是清空微任务队列,然后处理一个事件。但这里有一个很容易被忽略的错误认知,就是本次事件处理过程中调度的微任务要到下一个事件循环才会被清空。事实并非是这样的,让我们看一下事件处理的代码

@pragma("vm:entry-point", "call") 
static void _handleMessage(Function handler, var message) { 
    handler(message); 
    _runPendingImmediateCallback();
}
复制代码

这是ReceivePort接收到消息以后的消息处理函数,handler里面做的事情就是我们上面讲的那些套路的Completer.complete操作。也就是说,handler运行完成后,微任务队列里面已经放着Future的回调了。那接下来的_runPendingImmediateCallback就是去清空微任务队列。换句话说,就是本次事件处理过程中调度的微任务会在本次调用的最后被运行。

另外我们也可以看出来,处理一个事件可能并不需要占用多少资源,处理一个事件可能仅仅是Completer.complete这一个操作,反而接下来的微任务队列,也就是运行那些Future的回调则可能会占用更多的资源。所以,有人说微任务代表着轻量级操作,这个结论不一定对啊。

那么从更加贴近代码实现的角度,以时间为轴,事件循环可以用以下这张图来表示:

Screen Shot 2022-06-06 at 1.18.32 AM.png 最开始执行的是main函数。在其运行完成后会清空当前微任务队列。(从某种意义上讲,main函数可以看做是一个特殊的事件,整个Dart程序开始运行的第一个事件)。接着当有新的事件过来的时候,首先会执行事件的回调,按照我们前述的套路,此时有可能会有Completer.complete,也就是有Future的回调被调度到微任务队列中。然后在事件处理完成之后,则开始执行微任务,如此循环直到程序退出。

写在最后

写这篇文章是作者本人在之前学习Dart/Flutter时遇到的一些误区,以及通过阅读源码慢慢解开关于事件循环机制的许多疑惑而做一个小的总结希望能分享给大家。如果对你有所帮助的话请点个赞,如果大伙有什么疑问要讨论或者文章有错漏之处,也欢迎在评论区指出。另外,本文所述内容不仅限于Dart/Flutter。对于加深理解JavaScript的事件循环也应该有好处。

(全文完)

猜你喜欢

转载自juejin.im/post/7105857106791628837