干货 | Dart 并发机制详解

Dart 通过 async-await、isolate 以及一些异步类型概念 (例如 FutureStream) 支持了并发代码编程。本篇文章会对 async-await、FutureStream
进行简略的介绍,而侧重点放在 isolate 的讲解上。

在应用中,所有的 Dart 代码都在 isolate 中运行。每一个 Dart 的 isolate 都有独立的运行线程,它们无法与其他 isolate 共享可变对象。在需要进行通信的场景里,isolate 会使用消息机制。尽管 Dart 的 isolate 模型设计是基于操作系统提供的进程和线程等更为底层的原语进行设计的,但在本篇文章中,我们不对其具体实现展开讨论。

大部分 Dart 应用只会使用一个 isolate (即 主 isolate),同时你也可以创建更多的 isolate,从而在多个处理器内核上达成并行执行代码的目的。

多平台使用时注意

所有的 Dart 应用都可以使用 async-await、FutureStream
而 isolate 仅针对 原生平台的使用 进行实现。
使用 Dart 构建的网页应用可以 使用 Web Workers 实现相似的功能。

异步的类型和语法

如果你已经对 FutureStream 和 async-await 比较熟悉了,可以直接跳到 isolate 部分进行阅读。

Future 和 Stream 类型

Dart 语言和库通过 FutureStream 对象,来提供会在当前调用的未来返回某些值的功能。以 JavaScript 中的 Promise 为例,在 Dart 中一个最终会返回 int 类型值的 promise,应当声明为 Future<int>;一个会持续返回一系列 int 类型值的 promise,应当声明为 Stream<int>

让我们用 dart:io 来举另外一个例子。File 的同步方法 readAsStringSync() 会以同步调用的方式读取文件,在读取完成或者抛出错误前保持阻塞。这个会返回 String 类型的对象,或者抛出异常。而与它等效的异步方法 readAsString(),会在调用时立刻返回 Future<String> 类型的对象。在未来的某一刻,Future<String> 会结束,并返回一个字符串或错误。

为什么一个方法是同步的还是异步的会如此重要?因为大部分应用需要在同一时刻做很多件事。例如,应用可能会发起一个 HTTP 请求,同时在请求返回前对用户的操作做出不同的界面更新。异步的代码会有助于应用保持更高的可交互状态。

async-await 语法

asyncawait 关键字是用声明来定义异步函数和获取它们的结果的方式。

下面是一段同步代码调用文件 I/O 时阻塞的例子:

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

下面是类似的代码,但是变成了 异步调用

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 关键字,让原生代码 (文件 I/O) 执行的同时,其他的 Dart 代码 (例如事件处理器) 能继续执行。使用 await 后,_readFileAsync() 调用返回的 Future<String> 类型也转换为了 String。从而在将结果 content 赋予变量时,隐式转换为 String 类型。

await 关键字仅在函数体前定义了 async 的函数中有效。

如下图所示,无论是在 Dart VM 还是在系统中,Dart 代码都会在 readAsString() 执行非 Dart 代码时暂停。在 readAsString() 返回值后,Dart 代码将继续执行。

如果你想了解更多关于 asyncawaitFuture 的内容,可以访问
异步编程 codelab 进行学习。

Isolate 的工作原理

现代的设备通常会使用多核 CPU。开发者为了让程序在设备上有更好的表现,有时会使用共享内容的线程来并发运行代码。然而,状态的共享可能会 产生竞态条件,从而造成错误
也可能会增加代码的复杂度。

Dart 代码并不在多个线程上运行,取而代之的是它们会在 isolate 内运行。每一个 isolate 会有自己的堆内存,从而确保 isolate 之间互相隔离,无法互相访问状态。
由于这样的实现并不会共享内存,所以你也不需要担心 互斥锁和其他锁)。

在使用 isolate 时,你的 Dart 代码可以在同一时刻进行多个独立的任务,并且使用可用的处理器核心。Isolate 与线程和进程近似,但是每个 isolate 都拥有独立的内存,以及运行事件循环的独立线程。

主 isolate

在一般场景下,你完全无需关心 isolate。通常一个 Dart 应用会在主 isolate 下执行所有代码,如下图所示:

就算是只有一个 isolate 的应用,只要通过使用 async-await 来处理异步操作,也完全可以流畅运行。一个拥有良好性能的应用,会在快速启动后尽快进入事件循环。这使得应用可以通过异步操作快速响应对应的事件。

Isolate 的生命周期

如下图所示,每个 isolate 都是从运行 Dart 代码开始的,比如 main() 函数。执行的 Dart 代码可能会注册一些事件监听,例如处理用户操作或文件读写。当 isolate 执行的 Dart 代码结束后,如果它还需要处理已监听的事件,那么它依旧会继续被保持。处理完所有事件后,isolate 会退出。

事件处理

在客户端应用中,主 isolate 的事件队列内,可能会包含重绘的请求、点击的通知或者其他界面事件。例如,下图展示了包含四个事件的事件队列,队列会按照先进先出的模式处理事件。

如下图所示,在 main() 方法执行完毕后,事件队列中的处理才开始,此时处理的是第一个重绘的事件。而后主 isolate 会处理点击事件,接着再处理另一个重绘事件。

如果某个同步执行的操作花费了很长的处理时间,应用看起来就像是失去了响应。在下图中,处理点击事件的代码比较耗时,导致紧随其后的事件并没有及时处理。这时应用可能会产生卡顿,所有的动画都无法流畅播放。

在一个客户端应用中,耗时过长的同步操作,通常会导致 卡顿的动画。而最糟糕的是,应用界面可能完全失去响应。

后台运行对象

如果你的应用受到耗时计算的影响而出现卡顿,例如 解析较大的 JSON 文件
你可以考虑将耗时计算转移到单独工作的 isolate,通常我们称这样的 isolate 为 后台运行对象。下图展示了一种常用场景,你可以生成一个 isolate,它将执行耗时计算的任务,并在结束后退出。这个 isolate 工作对象退出时会把结果返回。

每个 isolate 都可以通过消息通信传递一个对象,这个对象的所有内容都需要满足可传递的条件。并非所有的对象都满足传递条件,在无法满足条件时,消息发送会失败。
举个例子,如果你想发送一个 List<Object>,你需要确保这个列表中所有元素都是可被传递的。假设这个列表中有一个 Socket,由于它无法被传递,所以你无法发送整个列表。

你可以查阅 send() 方法 的文档来确定哪些类型可以进行传递。

Isolate 工作对象可以进行 I/O 操作、设置定时器,以及其他各种行为。它会持有自己内存空间,与主 isolate 互相隔离。这个 isolate 在阻塞时也不会对其他 isolate 造成影响。

代码示例

本节将重点讨论使用 Isolate API 实现 isolate 的一些示例。

Flutter 开发提示

如果你在非 Web 平台上使用 Flutter 进行开发,那么与其直接使用 Isolate API,可以考虑使用 Flutter 提供的 compute() 方法compute() 方法能以简单的方式将一个函数的调用封装至 isolate 工作对象内。

实现一个简单的 isolate 工作对象

本节将展示一个主 isolate 与它生成的 isolate 工作对象的实现。Isolate 工作对象会执行一个函数,完成后结束对象,并将函数结果发送至主 isolate。(Flutter 提供的 compute() 方法也是以类似的方式工作的。)

下面的示例将使用到这些与 isolate 相关的 API:

主 isolate 的代码如下:

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

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

// Spawns an isolate and waits for the first message
Future<Map<String, dynamic>> _parseInBackground() async {
  final p = ReceivePort();
  await Isolate.spawn(_readAndParseJson, p.sendPort);
  return await p.first;
}

_parseInBackground() 方法包含了 生成 后台 isolate 工作对象的代码,并返回结果:

  1. 在生成 isolate 之前,代码创建了一个 ReceivePort,让 isolate 工作对象可以传递信息至主 isolate。
  2. 接下来是调用 Isolate.spawn(),生成并启动一个在后台运行的 isolate 工作对象。该方法的第一个参数是 isolate 工作对象执行的函数引用:_readAndParseJson。第二个参数则是 isolate 用来与主 isolate 传递消息的 SendPort。此处的代码并没有 创建 新的 SendPort,而是直接使用了 ReceivePortsendPort 属性。
  3. Isolate 初始化完成后,主 isolate 即开始等待它的结果。由于 ReceivePort 实现了 Stream,你可以很方便地使用 first 属性获得 isolate 工作对象返回的单个消息。

初始化后的 isolate 会执行以下代码:

Future _readAndParseJson(SendPort p) async {
  final fileData = await File(filename).readAsString();
  final jsonData = jsonDecode(fileData);
  Isolate.exit(p, jsonData);
}

在最后一句代码后,isolate 会退出,将 jsonData 通过传入的 SendPort 发送。
在 isolate 之间传递消息时,通常会发生数据拷贝,所耗费的时间随着数据的大小而发生改变,复杂度为 O(n)。然而,当你使用 Isolate.exit() 发送数据时,isolate 中持有的消息并没有发生拷贝,而是直接转移到了接收的 isolate 中。这样的转移速度很快,耗费的时间复杂度仅为 O(1)

Isolate.exit() 在 Dart 2.15 中被引入

在先前的 Dart 版本中,仅支持通过 Isolate.send() 进行显式的消息传递,
下一个小节的示例中将进行说明。

下图说明了主 isolate 和 isolate 工作对象之间的通信流程:

在 isolate 之间发送多次消息内容

如果你想在 isolate 之间建立更多的通信,那么你需要使用 SendPortsend() 方法。下图展示了一种常见的场景,主 isolate 会发送请求消息至 isolate 工作对象,然后它们之间会继续进行多次通信,进行请求和回复。

下方列举的 isolate 示例 包含了发送多次消息的使用方法:

  • send_and_receive.dart 展示了如何从主 isolate 发送消息至生成的 isolate。与前面的示例较为接近。
  • long_running_isolate.dart 展示了如何生成一个长期运行、且多次发送和接收消息的 isolate。

性能和 isolate 组

当一个 isolate 调用了 Isolate.spawn(),两个 isolate 将拥有同样的执行代码,并归入同一个 isolate 组 中。Isolate 组会带来性能优化,例如新的 isolate 会运行由 isolate 组持有的代码,即共享代码调用。同时,Isolate.exit() 仅在对应的 isolate 属于同一组时有效。

某些场景下,你可能需要使用 Isolate.spawnUri(),使用执行的 URI 生成新的 isolate,并且包含代码的副本。然而,spawnUri() 会比 spawn() 慢很多,并且新生成的 isolate 会位于新的 isolate 组。另外,当 isolate 在不同的组中,它们之间的消息传递会变得更慢。

在 Flutter 开发中请注意

Flutter 不支持 Isolate.spawnUri()

文章信息