Dart 中的并发

Last updated: ... / Reads: 36 Edit

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

Dart 中的并发编程指的是异步 API,例如 Future 和 Stream 以及隔离,它允许您将进程移动到单独的核心。

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

事件循环

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

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

event-loop

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

while (eventQueue.waitForEvent()) {
  eventQueue.processNextEvent();
}

此示例事件循环是同步的并且在单个线程上运行。然而,大多数 Dart 应用程序一次需要做不止一件事。例如,客户端应用程序可能需要执行 HTTP 请求,同时还侦听用户点击按钮。为了解决这个问题,Dart 提供了许多异步 API,例如 Futures、Streams 和 async-await。这些 API 是围绕此事件循环构建的。

例如,考虑发出网络请求:

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

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

async-event-loop

这个模型通常是事件循环处理 Dart 中所有其他异步事件(例如 Stream 对象)的方式。

异步编程

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

期货

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

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

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 关键字提供了一种声明性方式来定义异步函数并使用其结果。

下面是一些在等待文件 I/O 时发生阻塞的同步代码的示例:

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();
}

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

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();
}

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

await 关键字仅适用于函数体之前有 async 的函数。

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

basics-await

Dart 还支持流形式的异步代码。流在未来提供价值,并且随着时间的推移不断重复。随着时间的推移提供一系列 int 值的承诺的类型为 Stream

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

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

等待和产量

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

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

如果您想了解有关使用 async 、 await 、 Stream 和 Future 的更多信息,请访问异步编程 Codelab。

分离物

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

所有 Dart 代码都在隔离区内运行,而不是线程。使用隔离,您的 Dart 代码可以同时执行多个独立任务。隔离就像线程或进程,但每个隔离都有自己的内存和运行事件循环的单个线程。

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

使用隔离,您的 Dart 代码可以同时执行多个独立任务,并使用额外的处理器核心(如果可用)。隔离就像线程或进程,但每个隔离都有自己的内存和运行事件循环的单个线程。

只有 Dart Native 平台实现了isolate。要了解有关 Dart Web 平台的更多信息,请参阅 Web 上的并发部分。

主要分离物

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

basics-main-isolate

即使是单隔离程序也可以顺利执行。在继续执行下一行代码之前,这些应用程序使用 async-await 来等待异步操作完成。行为良好的应用程序启动速度很快,尽快进入事件循环。然后,应用程序立即响应每个排队的事件,并根据需要使用异步操作。

隔离生命周期

如下图所示,每个isolate都是从运行一些Dart代码开始的,例如 main() 函数。此 Dart 代码可能会注册一些事件侦听器,例如响应用户输入或文件 I/O。当isolate的初始函数返回时,如果它需要处理事件,isolate就会留下来。处理完事件后,isolate 退出。

basics-isolate

事件处理

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

event-loop

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

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

basics-main-isolate

在客户端应用程序中,太长的同步操作通常会导致 UI 动画卡顿(不流畅)。更糟糕的是,用户界面可能会变得完全无响应。

后台工作者

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

isolate-bg-worker

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

使用分离物

在 Dart 中,有两种处理隔离的方法,具体取决于用例:

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

在大多数情况下,建议使用 Isolate.run API 在后台运行进程。

Isolate.run()

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

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.spawn() 时,两个隔离具有相同的可执行代码并且位于同一隔离组中。隔离组可以实现性能优化,例如共享代码;新的隔离立即运行隔离组拥有的代码。此外, Isolate.exit() 仅当分离株位于同一分离株组中时才起作用。

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

分离株的局限性

隔离不是线程

如果您从具有多线程的语言开始使用 Dart,那么期望隔离的行为就像线程是合理的,但事实并非如此。每个隔离区都有自己的状态,确保隔离区中的任何状态都无法从任何其他隔离区访问。因此,隔离体受到对其自身内存的访问的限制。

例如,如果您有一个具有全局可变变量的应用程序,则该变量将是您生成的隔离中的单独变量。如果您在生成的隔离中改变该变量,它将在主隔离中保持不变。这就是分离物的作用方式,当您考虑使用分离物时,请务必记住这一点。

消息类型

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

具有本机资源的对象,例如 Socket 。

  • ReceivePort
  • DynamicLibrary
  • Finalizable
  • Finalizer
  • NativeFinalizer
  • Pointer
  • UserTag
  • 标有 @pragma('vm:isolate-unsendable') 的类的实例

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

请注意, Isolate.spawn() 和 Isolate.exit() 对 SendPort 对象进行抽象,因此它们受到相同的限制。

Web 上的并发

所有 Dart 应用程序都可以使用 async-await 、 Future 和 Stream 进行非阻塞、交错计算。然而,Dart Web 平台不支持隔离。 Dart Web 应用程序可以使用 Web Worker 在后台线程中运行脚本,类似于隔离。不过,网络工作者的功能和能力与隔离人员有些不同。

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

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

其他资源

  • 如果您使用许多隔离,请考虑 Flutter 中的 IsolateNameServer 或为非 Flutter Dart 应用程序提供类似功能的 package:isolate_name_server 。
  • 阅读有关 Actor 模型的更多信息,Dart 的隔离所基于该模型。
  • 有关 Isolate API 的其他文档:
    • Isolate.exit()
    • Isolate.spawn()
    • ReceivePort
    • SendPort

Comments

Make a comment