Dart中的事件循环(The Event Loop and Dart)

298 阅读9分钟

基本概念

如果您编写过 UI 代码,您可能熟悉事件循环(event loop)和事件队列(event queue)的概念。它们确保一次处理一个图形操作事件,例如鼠标单击。

Event loops and queues

事件循环的工作是从事件队列(event queue)中取出一个项目并处理它,只要队列有项目就重复这两个步骤。

image.png

队列中的项目可能代表用户输入、文件 I/O 通知、计时器等。例如,下面是包含计时器和用户输入事件的事件队列的图片:

image.png

所有这些都可能在你所熟悉的非 Dart 语言中很熟悉。现在让我们谈谈它如何融入 Dart 平台。

Dart 的单线程运行

一旦 Dart 函数开始执行,它就会一直执行直到退出。换句话说,Dart 函数不能被其他 Dart 代码中断。

注意:Dart 命令行应用程序可以通过创建isolates来并行运行代码。(Dart web 应用程序目前不能额外创建isolates,但它们可以创建workers。)isolates不共享内存;它们就像独立的应用程序,通过传递消息相互通信。除了应用程序显式在isolates或workers中运行的代码外,应用程序的所有代码都在应用程序的main isolate中运行。有关详细信息,请参阅本文后面的 Use isolates or workers if necessary

如下图所示,Dart 应用程序在main isolate执行应用程序的 main() 函数时开始执行。 main() 退出后,main isolate线程开始处理应用程序事件队列中的项目,一项接着一项。

image.png

实际上,这有点过于简单化了。

Dart 的事件循环和队列

Dart应用程序有一个带有两个队列的事件循环——事件队列(event queue)和微任务队列(microtask queue)。

事件队列包含所有外部事件:I/O、鼠标事件、绘图事件、计时器、Dart isolates 之间的消息等。

因为事件处理代码有时需要稍后完成,但是这个时刻是在将控制权返还给事件循环之前,所以微任务队列是必须的。例如,当一个可观察对象改变时,它可以将几个变化组合在一起并异步报出。微任务队列允许可观察对象在DOM显示不一致状态下,报出这些变化。

事件队列包含来自Dart和系统其他地方的事件。目前,微任务队列仅包含来自Dart代码的事件,但我们希望Web能够实现浏览器微任务队列。

如下图所示,当main()执行后,事件循环开始工作。首先,它以先进先出的形式处理所有微任务,微任务先出队并在事件循环中处理。重复执行这个循环,直到处理完所有的微任务。一旦两个队列都为空,而且没有预期事件,这个app就可以销毁了。

image.png

重要提示:当事件循环正在执行微任务队列中的任务时,事件队列卡住了:应用程序无法绘制图形、处理鼠标点击、对 I/O 做出反应等等。

尽管您可以预测任务执行的顺序,但您无法准确预测事件循环何时将任务从队列中取出。 Dart事件处理系统基于单线程循环;它不是基于时钟。例如,当您创建延迟任务时,事件会在您指定的时间入队。但是,只有在事件队列中的所有事件(以及微任务队列中的每个任务)都处理完之后,才能处理该事件。

futures链来制定任务顺序

如果您的代码具有依赖关系,为了将它们显式化表达。显式化表达有助于其他开发人员理解您的代码。

以下是错误编码方式的示例:

// BAD because of no explicit dependency between setting and using
// the variable.
future.then(...set an important variable...);
Timer.run(() {...use the important variable...});

正确方式:

// BETTER because the dependency is explicit.
future.then(...set an important variable...)
  .then((_) {...use the important variable...});

好的代码使用then()来明确必须先设置变量才能使用它。(如果您希望代码即使发生错误也能执行,您可以使用 whenComplete()而不是then()。)

如果使用该变量需要时间并且可以稍后完成,请考虑将该代码放在一个新的 Future 中:

// MAYBE EVEN BETTER: Explicit dependency plus delayed execution.
future.then(...set an important variable...)
  .then((_) {new Future(() {...use the important variable...})});

使用新的 Future 使事件循环有机会处理来自事件队列的其他事件。下一节将详细介绍如何调度稍后运行的代码。

如何调度任务

当您需要稍后执行的某些代码时,可以使用 dart:async 库提供的以下 API:

  1. Future 类,它将一个项目添加到事件队列的末尾。
  2. 顶层 scheduleMicrotask() 函数,将一个项目添加到微任务队列的末尾。

注意: scheduleMicrotask() 函数曾经被命名为 runAsync()。

使用恰当的队列(通常是事件队列)

通常情况下,使用 Future 在事件队列上安排任务。使用事件队列有助于减少微任务队列的长度,从而减少事件队列卡死的可能性。

如果在处理事件队列之前绝对必须完成任务,那么您通常应该立即执行该函数。如果不能立即执行,则使用 scheduleMicrotask() 将项目添加到微任务队列。例如,在 Web 应用程序中使用微任务来避免过早释放 js-interop 代理或结束 IndexedDB 事务或事件处理程序。

image.png

事件队列:new Future()

在事件队列上安排任务,请使用 new Future() 或 new Future.delayed()。这是 dart:async 库中定义的两个 Future 构造函数。

注意:您也可以使用 Timer 来安排任务,但如果任务中出现任何未捕获的异常,您的应用将退出。相反,我们推荐 Future,它建立在 Timer 之上,并增加了检测任务完成和响应错误等功能。

要立即将任务放入事件队列,请使用 new Future():

// Adds a task to the event queue.
new Future(() {
  // ...code goes here...
});

您可以添加对 then() 或 whenComplete() 的调用以便在新的 Future 完成后立即执行一些代码。例如,当新的 Future 的任务出队执行时,以下代码会打印“42”:

new Future(() => 21)
    .then((v) => v*2)
    .then((v) => print(v));

要在一段时间后执行任务,请使用 new Future.delayed():

// After a one-second delay, adds a task to the event queue.
new Future.delayed(const Duration(seconds:1), () {
  // ...code goes here...
});

尽管前面的示例在一秒钟后将任务添加到事件队列中,但直到主isolate空闲、微任务队列为空、事件队列中先前入队的任务执行完,该任务才能执行。例如,如果 main() 函数或事件处理程序正在运行昂贵的计算,则该任务在该计算完成之前无法执行。在这种情况下,延迟可能远超过一秒。

提示:如果您在 Web 应用程序中为动画绘制帧,请不要使用 Future(或 Timer 或 Stream)。 相反,使用 animationFrame,它是请求动画帧的 Dart 接口。

关于Future的一些总结:

  1. 当 Future 完成时,您传递给 Future 的 then() 方法的函数会立即执行。 (该函数没有入队,它只是被调用。)
  2. 如果 Future 在调用 then() 之前已经完成,则将一个任务添加到微任务队列中,这个任务用来执行then() 的函数。
  3. Future() 和 Future.delayed() 构造函数不会立即完成; 他们将一个项目添加到事件队列中。
  4. Future.value() 构造函数在一个微任务中完成,类似于 #2。
  5. Future.sync() 构造函数立即执行其函数,并且在微任务中完成(除非该函数返回 Future),类似于 #2。

Microtask queue: scheduleMicrotask()

dart:async库将scheduleMicrotask()定义为顶层函数,你可以像以下方式调用这个函数:

scheduleMicrotask(() {
  // ...code goes here...
});

必要情况下使用isolates或者workers

如果您要运行计算密集型任务怎么办? 为了让您的应用程序保持响应,您应该将任务放入isolates或workers中。 隔离可能在单独的进程或线程中运行,具体取决于 Dart 实现。

您应该使用多少个isolate? 对于计算密集型任务,您通常应该使用尽可能多的isolate,以使 CPU 更充分的利用。 如果它们纯粹是计算任务,那么任何额外的isolate都会被浪费掉。 但是,如果隔离器执行异步调用(例如执行 I/O),那么它们将不会在 CPU 上花费太多时间,因此使用更多的isolate将更有意义。

如果这对你的应用来说是一个好的架构,你也可以使用比 CPU 核心更多的isolate。 例如,您可以为每个功能使用单独的isolate,或者当您需要确保数据不共享时。

两个问题

第一个问题

import 'dart:async';
void main() {
  print('main #1 of 2');
  scheduleMicrotask(() => print('microtask #1 of 2'));

  new Future.delayed(new Duration(seconds:1),
                     () => print('future #1 (delayed)'));
  new Future(() => print('future #2 of 3'));
  new Future(() => print('future #3 of 3'));

  scheduleMicrotask(() => print('microtask #2 of 2'));

  print('main #2 of 2');
}

答案

main #1 of 2
main #2 of 2
microtask #1 of 2
microtask #2 of 2
future #2 of 3
future #3 of 3
future #1 (delayed)

该顺序应该是您所期望的,因为示例的代码分三批执行:

  1. 在main()函数中的代码
  2. 在microtask queue中的代码
  3. 在event queue中的代码

请记住,main() 函数中的所有调用都是同步执行的,从开始到结束。 首先 main() 调用 print(),然后是 scheduleMicrotask(),然后是 new Future.delayed(),然后是 new Future(),以此类推。 只有回调——闭包体中的代码被指定为 scheduleMicrotask()、new Future.delayed() 和 new Future() 的参数——稍后执行。

总结

  • Dart 应用程序的事件循环从两个队列执行任务:事件队列和微任务队列。
  • 事件队列包含来自 Dart(future、计时器、isolate message等)和系统(用户操作、I/O 等)的项目。
  • 目前,微任务队列只有来自 Dart 的项目,但我们希望它与浏览器微任务队列合并。
  • 事件循环在出队和处理事件队列中的下一项之前清空微任务队列。
  • 一旦两个队列都为空,应用程序就完成了它的工作并且(取决于它的嵌入器)可以退出。
  • main() 函数以及来自微任务和事件队列的所有项目都在 Dart 应用程序的主隔离上运行。

安排任务时,请遵循以下规则:

  • 如果可能,将其放入事件队列(使用 new Future() 或 new Future.delayed())。
  • 使用 Future 的 then() 或 whenComplete() 方法来指定任务顺序。
  • 为避免使事件循环死亡,请保持微任务队列尽可能短。
  • 为了让您的应用程序保持响应,请避免在任一事件循环上执行计算密集型任务。
  • 要执行计算密集型任务,请创建额外的isolates或workers。