概览
本文包含了 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。