概览

本文包含了 Dart 中并发编程工作原理的概念性概述。它从较高层面解释了事件循环、异步语言特性和隔离区。

Dart 中的并发编程既指异步 API(如 Future 和 Stream),也指隔离区,隔离区允许你将进程转移到独立的核心上。

所有 Dart 代码都在隔离区中运行,从默认的主隔离区开始,并且可以选择性地扩展到你显式创建的任何后续隔离区。当你生成一个新的隔离区时,它拥有自己独立的内存和自己的事件循环。事件循环是 Dart 中实现异步和并发编程的关键。

事件循环

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

当你的应用程序运行时,所有事件都会被添加到一个名为事件队列的队列中。事件可以是任何事情,从重新绘制用户界面的请求,到用户的点击和按键操作,再到来自磁盘的输入 / 输出。由于你的应用程序无法预测事件发生的顺序,事件循环会按照事件入队的顺序逐个处理它们。

在这里插入图片描述
感觉和handle,looper机制很像。
事件循环的运行方式与这段代码相似

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

就是一直等待事件进入事件队列,总是取出最新的事件执行。

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

下面的例子中发起了一个网络请求
在这里插入图片描述
使用http库需要导包,可能会很慢,没有上网工具的话可以配置一下镜像地址。
在这里插入图片描述
当这段代码进入事件循环时,它会立即调用第一个子句 http.get,并返回一个 Future 对象。它还会告知事件循环,在 then () 子句中的回调函数要一直保留,直到 HTTP 请求完成解析。当请求解析完成后,事件循环就会执行该回调函数,并将请求的结果作为参数传入。
在这里插入图片描述
在这里插入图片描述

在这里插入图片描述
Dart 中的事件循环处理所有其他异步事件(例如 Stream 对象)时,通常采用的就是这种相同的模型。

Asynchronous programming

Futures

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

如果想要获取错误信息只需要和我一样,传递第二个参数
在这里插入图片描述

The async-await syntax

async 和 await 关键字提供了一种声明式的方式来定义异步函数并使用它们的结果。

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

const String filename =
    'C:\\Users\\Administrator\\Documents\\trae_projects\\dartStuday\\my_dart_project\\bin\\a.txt';

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

下面是类似的代码,但做了一些修改(已突出显示)以使其成为异步代码:
在这里插入图片描述

main () 函数在_readFileAsync () 前使用 await 关键字,以便在原生代码(文件 I/O)执行时,让其他 Dart 代码(如事件处理程序)能够使用 CPU。使用 await 还有一个作用,就是将_readFileAsync () 返回的 Future<String>转换为 String。因此,contents 变量的隐式类型为 String。

await 关键字仅在函数体前带有 async 的函数中起作用。

如下图所示,在 readAsString () 执行非 Dart 代码(无论是在 Dart 运行时还是操作系统中)时,Dart 代码会暂停。一旦 readAsString () 返回一个值,Dart 代码的执行就会恢复。
在这里插入图片描述

Streams

Dart 还以流的形式支持异步代码。流会在未来提供值,并且会随着时间的推移反复提供。一个承诺会随着时间的推移提供一系列 int 值的对象,其类型为 Stream。

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

Stream<int> stream =
      Stream.periodic(const Duration(seconds: 1), (i) => i * i);
  stream.listen((data) {
    print(data);
  });

下面是periodic函数的注释
/// 创建一个以指定[周期]为间隔、持续发送事件的流。
///
/// 事件值由调用[计算方法]生成。该回调函数的入参是一个整数,
/// 初始值为 0,每发送一个事件,该值自增 1。
在这里插入图片描述

await-for and yield

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*和async的区别
在这里插入图片描述

Isolates 隔离

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

与线程不同,所有 Dart 代码都在隔离区内部运行。借助隔离区,你的 Dart 代码可以同时执行多个独立任务,并且在有额外处理器内核可用时会加以利用。隔离区类似于线程或进程,但每个隔离区都有自己的内存和一个运行事件循环的单线程。

每个隔离区都有自己的全局字段,这确保了一个隔离区中的任何状态都无法从其他隔离区访问。隔离区之间只能通过消息传递进行通信。隔离区之间没有共享状态,这意味着 Dart 中不会出现像互斥锁、锁以及数据竞争这类并发复杂性问题。话虽如此,隔离区也不能完全防止竞态条件。

The main isolate 主隔离区

在大多数情况下,你根本无需考虑隔离区。Dart 程序默认在主隔离区中运行。
这是程序开始运行和执行的线程,如下图所示:
在这里插入图片描述
即便是单隔离程序也能顺畅运行。
在执行下一行代码之前,这些应用会使用异步等待(async-await)来等待异步操作完成。一个运行良好的应用启动迅速,能尽快进入事件循环。之后,该应用会及时响应每个排队的事件,并在必要时使用异步操作。

The isolate life cycle

如下图所示,每个隔离区都从运行一些 Dart 代码开始,例如 main () 函数。此 Dart 代码可能会注册一些事件监听器 —— 例如,用于响应用户输入或文件 I/O。当隔离区的初始函数返回时,如果需要处理事件,隔离区会继续存在。处理完事件后,隔离区便会退出。

在这里插入图片描述

Event handling

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

在这里插入图片描述
事件处理在 main () 退出后发生在主隔离区。在下图中,main () 退出后,主隔离区处理第一个重绘事件。之后,主隔离区处理点击事件,随后是一个重绘事件。

如果同步操作占用过多处理时间,应用程序可能会变得无响应。在下图中,处理点击的代码耗时过长,因此后续事件的处理也会延迟。应用程序可能会出现冻结现象,其执行的任何动画也可能会卡顿。
在这里插入图片描述

在客户端应用中,过长的同步操作往往会导致卡顿(不流畅)的用户界面动画。更糟糕的是,用户界面可能会完全失去响应。

Background workers

如果你的应用程序的用户界面因耗时的计算(例如解析大型 JSON 文件)而变得无响应,可以考虑将该计算任务转移到工作隔离区(通常称为后台工作线程)。如下图所示,一种常见的情况是生成一个简单的工作隔离区,由它执行计算然后退出。工作隔离区在退出时会通过消息返回其结果。
在这里插入图片描述
工作器隔离区可以执行输入 / 输出操作(例如,读取和写入文件)、设置计时器等。它有自己的内存,并且不与主隔离区共享任何状态。工作器隔离区可以阻塞,而不会影响其他隔离区。

Using isolates

在 Dart 中使用隔离区有两种方式,具体取决于使用场景:

  • 使用 Isolate.run () 在单独的线程上执行单个计算。
  • 使用 Isolate.spawn () 创建一个隔离区,它将长期处理多条消息,或者作为后台工作程序。

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

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

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

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

下面是Isolate.spawn()函数的使用案例

import 'dart:isolate';

// 主隔离区
void main() async {
  print('主隔离区开始,ID: ${Isolate.current.hashCode}');

  // 创建接收端口
  final receivePort = ReceivePort();

  // 创建新隔离区
  final isolate = await Isolate.spawn(
    _isolateEntry, // 入口函数
    receivePort.sendPort, // 初始消息
  );

  // 监听子隔离区消息
  receivePort.listen((message) {
    print('收到子隔离区消息: $message');

    if (message == 'done') {
      receivePort.close();
      isolate.kill(); // 停止隔离区
    }
  });

  print('子隔离区已创建,ID: ${isolate.hashCode}');
}

// 子隔离区入口函数(必须是顶层或静态函数)
void _isolateEntry(SendPort sendPort) {
  print('子隔离区启动,ID: ${Isolate.current.hashCode}');
  // 向主隔离区发送消息
  sendPort.send('Hello from isolate!');

  // 模拟工作
  for (int i = 0; i < 3; i++) {
    sendPort.send('进度: $i');
  }

  sendPort.send('done');
}

Performance and isolate groups

当一个隔离区调用 Isolate.spawn () 时,这两个隔离区拥有相同的可执行代码,并且处于同一个隔离区组中。隔离区组支持诸如代码共享等性能优化;新的隔离区会立即运行该隔离区组所拥有的代码。此外,只有当隔离区处于同一个隔离区组时,Isolate.exit () 才会生效。

在某些特殊情况下,你可能需要使用 Isolate.spawnUri (),它会利用指定 URI 处代码的副本来设置新的隔离区。不过,spawnUri () 比 spawn () 慢得多,而且新的隔离区不在其生成器的隔离组中。另一个性能影响是,当隔离区位于不同的组中时,消息传递会更慢。

Isolate.spawnUri ()主要是用在启动外部的代码副本,下面看案例

import 'dart:isolate';
import 'dart:io';

void main() async {
  final receivePort = ReceivePort();
  
  // 创建指向外部 Dart 文件的 URI
  final uri = Uri.file('path/to/external_worker.dart');
  
  // 使用 spawnUri 创建隔离区
  final isolate = await Isolate.spawnUri(
    uri,                     // 外部文件 URI
    [],                      // 参数列表(传递给 main 函数)
    receivePort.sendPort,    // 初始消息
  );
  
  receivePort.listen((message) {
    print('外部隔离区消息: $message');
  });
  await Future.delayed(Duration(seconds: 5));
  
  isolate.kill();  // ✅ 可以停止,但没有exit优雅
  
  print('隔离区已停止');
  
  // 验证是否停止
  print('隔离区是否存活?');
  // 实际上没有直接的 isAlive 方法,但 kill 后资源会被回收
}
// external_worker.dart - 外部文件
import 'dart:isolate';

// 这是 spawnUri 的入口点
void main(List<String> args, SendPort sendPort) {
  // args 来自 spawnUri 的第二个参数
  // sendPort 来自 spawnUri 的第三个参数
  
  sendPort.send('来自外部文件的问候!');
  sendPort.send('参数: $args');
}

Limitations of isolates隔离的局限性

隔离区不是线程。
如果你是从一种支持多线程的语言转而使用 Dart,那么期望隔离区的行为像线程一样是合情合理的,但事实并非如此。每个隔离区都有自己的状态,这确保了一个隔离区中的任何状态都无法被其他隔离区访问。因此,隔离区的能力受到它们对自身内存访问的限制。

例如,如果你有一个包含全局可变变量的应用程序,该变量在你生成的隔离区中会是一个独立的变量。如果你在生成的隔离区中修改了该变量,主隔离区中的该变量仍会保持不变。这就是隔离区的预期工作方式,在你考虑使用隔离区时,记住这一点很重要。

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

  • 绑定到特定隔离区的资源(Socket、ReceivePort)
  • 本地交互对象(Pointer、DynamicLibrary)
  • 内存管理对象(Finalizer)
  • 标记为不可发送的自定义类

设计启示:

  • 隔离区通信应该传递数据,而不是资源
  • 对于需要跨隔离区使用的资源,传递配置并在目标隔离区重新创建
  • 在设计跨隔离区API时,考虑对象的可序列化性

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

能够并行运行的隔离区数量是有限制的。不过,这个限制并不会影响 Dart 中隔离区之间通过消息进行的标准异步通信。你可以让数百个隔离区同时运行并推进工作。这些隔离区会以轮询的方式在 CPU 上进行调度,并且经常相互让出执行权。

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

为避免这种情况,执行同步阻塞的 C 代码需要在执行阻塞操作前离开当前的隔离区,并在从 FFI 调用返回 Dart 之前重新进入该隔离区

隔离区在web

所有 Dart 应用都可以使用 async-await、Future 和 Stream 来进行非阻塞、交错的计算。不过,Dart Web 平台不支持隔离区。Dart Web 应用可以使用 Web Worker 在后台线程中运行脚本,这与隔离区类似。但 Web Worker 的功能和性能与隔离区存在一定差异。

例如,当 Web Worker 在线程之间发送数据时,它们会来回复制数据。不过,数据复制可能会非常缓慢,尤其是对于大型消息而言。隔离区(Isolates)也会做同样的事情,但还提供了一些 API,这些 API 能够更高效地传输存储消息的内存。

创建 Web Worker 和隔离区(Isolate)的方式也有所不同。你只能通过声明一个单独的程序入口点并对其进行单独编译来创建 Web Worker。启动 Web Worker 类似于使用 Isolate.spawnUri 来启动隔离区。你也可以使用 Isolate.spawn 来启动隔离区,这种方式需要的资源更少,因为它会复用一些与生成它的隔离区相同的代码和数据。而 Web Worker 没有与之等效的 API。

Logo

有“AI”的1024 = 2048,欢迎大家加入2048 AI社区

更多推荐