跳到主要内容

Dart 中的并发

本页包含关于 Dart 中并发编程如何工作的概念性概述。它从高层解释了事件循环、异步语言特性和 Isolate 隔离区。有关在 Dart 中使用并发的更实际的代码示例,请阅读异步支持页面和Isolate 隔离区页面。

Dart 中的并发编程指的是异步 API(如 FutureStream)以及Isolate 隔离区,后者允许您将进程移动到单独的核心。

所有 Dart 代码都在 Isolate 隔离区中运行,从默认的主 Isolate 隔离区开始,并可选择扩展到您显式创建的任何后续 Isolate 隔离区。当您生成新的 Isolate 隔离区时,它有自己的隔离内存和自己的事件循环。事件循环是使 Dart 中的异步和并发编程成为可能的原因。

事件循环

#

Dart 的运行时模型基于事件循环。事件循环负责执行您的程序代码、收集和处理事件等等。

当您的应用程序运行时,所有事件都会添加到队列中,称为事件队列。事件可以是任何事物,从重绘 UI 的请求,到用户点击和按键,再到来自磁盘的 I/O。由于您的应用无法预测事件发生的顺序,因此事件循环会按照事件排队的顺序逐个处理事件。

A figure showing events being fed, one by one, into the
event loop

事件循环的运作方式类似于以下代码

dart 命令
while (eventQueue.waitForEvent()) {
  eventQueue.processNextEvent();
}

这个示例事件循环是同步的,并在单线程上运行。但是,大多数 Dart 应用程序需要一次执行多项操作。例如,客户端应用程序可能需要执行 HTTP 请求,同时监听用户点击按钮。为了处理这种情况,Dart 提供了许多异步 API,如Future、Stream 和 async-await。这些 API 围绕此事件循环构建。

例如,考虑发出网络请求

dart 命令
http.get('https://example.com').then((response) {
  if (response.statusCode == 200) {
    print('Success!');
  }  
}

当此代码到达事件循环时,它会立即调用第一个子句 http.get,并返回一个 Future。它还会告诉事件循环保留 then() 子句中的回调,直到 HTTP 请求解决。当这种情况发生时,它应该执行该回调,并将请求的结果作为参数传递。

Figure showing async events being added to an event loop and
holding onto a callback to execute later
.

这种相同的模型通常是事件循环如何处理 Dart 中的所有其他异步事件的方式,例如 Stream 对象。

异步编程

#

本节总结了 Dart 中异步编程的不同类型和语法。如果您已经熟悉 FutureStream 和 async-await,则可以跳到 Isolate 隔离区部分

Future

#

Future 表示异步操作的结果,该操作最终将完成并返回一个值或错误。

在此示例代码中,Future<String> 的返回类型表示最终提供 String 值(或错误)的承诺。

dart 命令
Future<String> _readFileAsync(String filename) {
  final file = File(filename);

  // .readAsString() returns a Future.
  // .then() registers a callback to be executed when `readAsString` resolves.
  return file.readAsString().then((contents) {
    return contents.trim();
  });
}

async-await 语法

#

asyncawait 关键字提供了一种声明式方式来定义异步函数并使用其结果。

这是一个同步代码的示例,它在等待文件 I/O 时会阻塞

dart 命令
const String filename = 'with_keys.json';

void main() {
  // Read some data.
  final fileData = _readFileSync();
  final jsonData = jsonDecode(fileData);

  // Use that data.
  print('Number of JSON keys: ${jsonData.length}');
}

String _readFileSync() {
  final file = File(filename);
  final contents = file.readAsStringSync();
  return contents.trim();
}

这是一个类似的代码,但进行了更改(突出显示)以使其异步

dart 命令
const String filename = 'with_keys.json';

void main() async {
  // Read some data.
  final fileData = await _readFileAsync();
  final jsonData = jsonDecode(fileData);

  // Use that data.
  print('Number of JSON keys: ${jsonData.length}');
}

Future<String> _readFileAsync() async {
  final file = File(filename);
  final contents = await file.readAsString();
  return contents.trim();
}

main() 函数在 _readFileAsync() 前面使用 await 关键字,以便其他 Dart 代码(例如事件处理程序)可以在本机代码(文件 I/O)执行时使用 CPU。使用 await 还具有将 _readFileAsync() 返回的 Future<String> 转换为 String 的效果。因此,contents 变量具有隐式类型 String

如下图所示,Dart 代码在 readAsString() 执行非 Dart 代码(在 Dart 运行时或操作系统中)时暂停。一旦 readAsString() 返回值,Dart 代码执行就会恢复。

Flowchart-like figure showing app code executing from start to exit, waiting
for native I/O in between

Stream

#

Dart 还以 Stream 的形式支持异步代码。Stream 在未来以及随着时间的推移重复提供值。随着时间的推移提供一系列 int 值的承诺具有类型 Stream<int>

在以下示例中,使用 Stream.periodic 创建的 Stream 每秒重复发出一个新的 int 值。

dart 命令
Stream<int> stream = Stream.periodic(const Duration(seconds: 1), (i) => i * i);

await-for 和 yield

#

Await-for 是一种 for 循环类型,它在提供新值时执行循环的每个后续迭代。换句话说,它用于“循环遍历” Stream。在此示例中,当从作为参数提供的 Stream 发出新值时,将从函数 sumStream 发出新值。yield 关键字用于返回 Stream 值的函数中,而不是 return

dart 命令
Stream<int> sumStream(Stream<int> stream) async* {
  var sum = 0;
  await for (final value in stream) {
    yield sum += value;
  }
}

如果您想了解更多关于使用 asyncawaitStreamFuture 的信息,请查看异步编程教程

Isolate 隔离区

#

除了异步 API 之外,Dart 还通过 Isolate 隔离区支持并发。大多数现代设备都具有多核 CPU。为了利用多核,开发人员有时会使用并发运行的共享内存线程。但是,共享状态并发容易出错,并可能导致代码复杂化。

所有 Dart 代码都在 Isolate 隔离区内运行,而不是线程。使用 Isolate 隔离区,您的 Dart 代码可以一次执行多个独立的任务,如果可用,则可以使用额外的处理器核心。Isolate 隔离区类似于线程或进程,但每个 Isolate 隔离区都有自己的内存和一个运行事件循环的单线程。

每个 Isolate 隔离区都有自己的全局字段,确保 Isolate 隔离区中的任何状态都无法从任何其他 Isolate 隔离区访问。Isolate 隔离区只能通过消息传递相互通信。Isolate 隔离区之间没有共享状态意味着诸如互斥锁或锁数据竞争之类的并发复杂性不会在 Dart 中发生。也就是说,Isolate 隔离区并不能完全阻止竞争条件。有关此并发模型的更多信息,请阅读关于Actor 模型的内容。

主 Isolate 隔离区

#

在大多数情况下,您根本不需要考虑 Isolate 隔离区。Dart 程序默认在主 Isolate 隔离区中运行。它是程序开始运行和执行的线程,如下图所示

A figure showing a main isolate, which runs , responds to events,
and then exits

即使是单 Isolate 隔离区程序也可以流畅地执行。在继续执行下一行代码之前,这些应用使用async-await来等待异步操作完成。一个行为良好的应用会快速启动,并尽快进入事件循环。然后,应用会及时响应每个排队的事件,并在必要时使用异步操作。

Isolate 隔离区生命周期

#

如下图所示,每个 Isolate 隔离区都通过运行一些 Dart 代码(例如 main() 函数)来启动。此 Dart 代码可能会注册一些事件侦听器,例如,响应用户输入或文件 I/O。当 Isolate 隔离区的初始函数返回时,如果需要处理事件,则 Isolate 隔离区会保留。处理完事件后,Isolate 隔离区退出。

A more general figure showing that any isolate runs some code, optionally responds to events, and then exits

事件处理

#

在客户端应用中,主 Isolate 隔离区的事件队列可能包含重绘请求以及点击和其他 UI 事件的通知。例如,下图显示了一个重绘事件,后跟一个点击事件,再后跟两个重绘事件。事件循环以先进先出的顺序从队列中获取事件。

A figure showing events being fed, one by one, into the event loop

事件处理发生在 main() 退出后的主 Isolate 隔离区上。在下图中,在 main() 退出后,主 Isolate 隔离区处理第一个重绘事件。之后,主 Isolate 隔离区处理点击事件,然后是重绘事件。

如果同步操作占用过多处理时间,则应用可能会变得无响应。在下图中,点击处理代码花费的时间太长,因此后续事件处理得太晚。应用可能会出现冻结,并且它执行的任何动画都可能会变得卡顿。

A figure showing a tap handler with a too-long execution time

在客户端应用中,同步操作耗时过长的结果通常是卡顿(不流畅)的 UI 动画。更糟糕的是,UI 可能会完全无响应。

后台工作器

#

如果您的应用的 UI 由于耗时的计算(例如,解析大型 JSON 文件)而变得无响应,请考虑将该计算卸载到工作器 Isolate 隔离区,通常称为后台工作器。下图显示了一个常见案例,即生成一个简单的工作器 Isolate 隔离区,该 Isolate 隔离区执行计算然后退出。工作器 Isolate 隔离区在其退出时在消息中返回其结果。

A figure showing a main isolate and a simple worker isolate

工作器 Isolate 隔离区可以执行 I/O(例如,读取和写入文件)、设置计时器等等。它有自己的内存,并且不与主 Isolate 隔离区共享任何状态。工作器 Isolate 隔离区可以阻塞,而不会影响其他 Isolate 隔离区。

使用 Isolate 隔离区

#

在 Dart 中,有两种使用 Isolate 隔离区的方法,具体取决于用例

  • 使用 Isolate.run() 在单独的线程上执行单个计算。
  • 使用 Isolate.spawn() 创建一个 Isolate 隔离区,该 Isolate 隔离区将随时间推移处理多个消息,或者作为后台工作器。有关使用长期存在的 Isolate 隔离区的更多信息,请阅读Isolate 隔离区页面。

在大多数情况下,Isolate.run 是推荐的在后台运行进程的 API。

Isolate.run()

#

静态 Isolate.run() 方法需要一个参数:将在新生成的 Isolate 隔离区上运行的回调。

dart 命令
int slowFib(int n) => n <= 1 ? 1 : slowFib(n - 1) + slowFib(n - 2);

// Compute without blocking current isolate.
void fib40() async {
  var result = await Isolate.run(() => slowFib(40));
  print('Fib(40) = $result');
}

性能和 Isolate 隔离区组

#

当 Isolate 隔离区调用 Isolate.spawn() 时,这两个 Isolate 隔离区具有相同的可执行代码,并且位于相同的Isolate 隔离区组中。Isolate 隔离区组实现了性能优化,例如共享代码;新的 Isolate 隔离区立即运行 Isolate 隔离区组拥有的代码。此外,Isolate.exit() 仅在 Isolate 隔离区位于同一 Isolate 隔离区组中时才起作用。

在某些特殊情况下,您可能需要使用 Isolate.spawnUri(),它使用位于指定 URI 的代码副本设置新的 Isolate 隔离区。但是,spawnUri()spawn() 慢得多,并且新的 Isolate 隔离区不在其生成器的 Isolate 隔离区组中。另一个性能后果是,当 Isolate 隔离区位于不同的组中时,消息传递速度较慢。

Isolate 隔离区的局限性

#

Isolate 隔离区不是线程

#

如果您是从具有多线程的语言转向 Dart,那么期望 Isolate 隔离区的行为类似于线程是合理的,但事实并非如此。每个 Isolate 隔离区都有自己的状态,确保 Isolate 隔离区中的任何状态都无法从任何其他 Isolate 隔离区访问。因此,Isolate 隔离区受其自身内存访问的限制。

例如,如果您的应用程序具有全局可变变量,则该变量将是您生成的 Isolate 隔离区中的一个单独变量。如果您在生成的 Isolate 隔离区中修改该变量,它将在主 Isolate 隔离区中保持不变。这就是 Isolate 隔离区的预期功能,并且在您考虑使用 Isolate 隔离区时,牢记这一点非常重要。

消息类型

#

通过 SendPort 发送的消息几乎可以是任何类型的 Dart 对象,但有一些例外

除了这些例外,任何对象都可以发送。查看 SendPort.send 文档以获取更多信息。

请注意,Isolate.spawn()Isolate.exit() 抽象于 SendPort 对象之上,因此它们也受到相同的限制。

Isolate 隔离区之间的同步阻塞通信

#

可以并行运行的 Isolate 隔离区数量是有限制的。此限制不影响 Dart 中 Isolate 隔离区之间通过消息进行的标准异步通信。您可以同时运行数百个 Isolate 隔离区并取得进展。Isolate 隔离区以轮询方式在 CPU 上调度,并经常相互让步。

Isolate 隔离区只能在纯 Dart 之外同步通信,通过 FFI 使用 C 代码来实现。如果 Isolate 隔离区的数量超过限制,除非采取特殊措施,否则尝试通过 FFI 调用中的同步阻塞在 Isolate 隔离区之间进行同步通信可能会导致死锁。该限制并非硬编码为特定数字,而是根据 Dart VM 堆大小(Dart 应用程序可用)计算得出的。

为了避免这种情况,执行同步阻塞的 C 代码需要在执行阻塞操作之前离开当前 Isolate 隔离区,并在从 FFI 调用返回到 Dart 之前重新进入它。阅读关于 Dart_EnterIsolateDart_ExitIsolate 的内容以了解更多信息。

Web 上的并发

#

所有 Dart 应用都可以使用 async-await、Future 和 Stream 进行非阻塞、交错的计算。但是,Dart Web 平台不支持 Isolate 隔离区。Dart Web 应用可以使用 Web Worker 在类似于 Isolate 隔离区的后台线程中运行脚本。Web Worker 的功能和能力与 Isolate 隔离区有些不同。

例如,当 Web Worker 在线程之间发送数据时,它们会在线程之间来回复制数据。数据复制可能非常慢,尤其是对于大型消息。Isolate 隔离区也执行相同的操作,但也提供 API,这些 API 可以更有效地传输保存消息的内存。

创建 Web Worker 和 Isolate 隔离区也不同。您只能通过声明单独的程序入口点并单独编译它来创建 Web Worker。启动 Web Worker 类似于使用 Isolate.spawnUri 启动 Isolate 隔离区。您还可以使用 Isolate.spawn 启动 Isolate 隔离区,这需要的资源更少,因为它重用了一些与生成 Isolate 隔离区相同的代码和数据。Web Worker 没有等效的 API。

其他资源

#