Dart 线程模型和异步
上一篇介绍了 Dart 的基础语法,其中异步编程章节简单提到了 Future、async/await 和 Stream 的基本用法。但很多关键问题还没有展开:为什么常说 Dart 是单线程?单线程怎么处理并发?事件循环到底怎么工作?Isolate 又是什么?它和线程有什么区别?本文把这些问题讲清楚。
一、Dart 的线程模型
1.1 单线程事件循环
Dart 的运行时基于事件循环(Event Loop)。所有 Dart 代码都运行在 isolate 中,默认运行在 main isolate 里。事件循环负责执行代码、收集和处理事件。
应用运行时,所有事件都被添加到事件队列(Event Queue)中。事件可以是 UI 重绘请求、用户点击、键盘输入、磁盘 I/O 等。因为应用无法预测事件的发生顺序,事件循环按入队顺序逐一处理:
1
2
3
4
// 伪代码
while (eventQueue.waitForEvent()) {
eventQueue.processNextEvent();
}
这段伪代码来自 Dart 官方文档,它揭示了事件循环的核心:取出一个事件,处理它,再取下一个,循环往复。这个循环是同步的,运行在单个线程上。
这和 Kotlin 协程的调度模型有明显区别。Dart 的 async/await 不会为当前函数自动创建新 isolate,它的续体仍由当前 isolate 的事件循环执行。Kotlin 协程则由 CoroutineDispatcher 决定在哪个线程或线程池上运行,既可以限制在单个线程,也可以在挂起和恢复后切换线程。
如果你有 Android 开发背景,可以把核心概念直接做个映射:
| Android(Java/Kotlin/JVM) | Flutter(Dart) | 共同点 / 差异 |
|---|---|---|
| Main 线程 + Looper/Handler | Root Isolate + Event Loop | 都是基于事件驱动的单线程死循环。Android 用 Looper 捞 Message;Dart 用 Event Loop 捞 Event |
| 子线程(Thread / 线程池) | 子 Isolate(Isolate.run / spawn) | 都可用于处理后台耗时任务。Android/JVM 默认共享堆内存,需要处理锁和可见性;Dart isolate 不共享普通可变对象,靠端口传数据 |
| Kotlin 协程(Coroutines) | Dart 异步(async / await) | 都能用顺序代码表达异步等待;Dart 在当前 isolate 的事件循环上恢复,Kotlin 的执行线程由 CoroutineDispatcher 决定 |
那单线程怎么处理耗时操作?答案是异步 API。当代码执行到异步操作(如网络请求)时,Dart 会将操作交给运行时或操作系统处理,同时注册一个回调,然后立即返回继续执行下一行代码。当异步操作完成后,结果作为事件放入队列,事件循环在合适的时候取出并执行回调。
下面这张图先把 Event Loop 的整体流程串起来:
1.2 事件队列与微任务队列
实际上,Dart 的事件循环维护两个队列:
- 微任务队列(Microtask Queue):优先级极高,用于非常短的内部异步操作。
- 事件队列(Event Queue):优先级较低,处理 I/O、手势、定时器、渲染等。
事件循环的调度规则是:先清空微任务队列中的所有任务,再从事件队列中取出一个事件处理。处理完一个事件后,再次检查微任务队列。这意味着微任务的优先级始终高于事件队列中的任务。
1
2
3
检查微任务队列是否有任务? ──(有)──> 执行微任务 ──> 循环检查直到清空
↓ (无)
检查事件队列是否有任务? ──(有)──> 执行 1 个事件 ──> 返回顶部重新检查
换成执行顺序图,就是下面这样:
如果你有 Android 经验,会联想到 Handler 和 MessageQueue。Dart 中的 Future(() => ...) 可以粗略类比为 Android 中写 Handler.post { ... },都是往队列扔一个任务。但有一个关键区别:Android 的 MessageQueue 本质上是单个队列,通过同步屏障(Sync Barrier)机制临时拦截同步消息、放行异步消息(用于优先处理 UI 渲染)。Dart 则是物理分离的两个队列,微任务队列在每次事件处理完毕后都会被完整清空,优先级保证更强。
用 scheduleMicrotask() 或 Future.microtask() 可以将任务加入微任务队列:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
import 'dart:async';
void main() {
print('1'); // 同步执行
Future(() => print('4')); // 进入事件队列
scheduleMicrotask(() => print('3')); // 进入微任务队列
print('2'); // 同步执行
// main() 结束后,事件循环启动:
// 先清空微任务队列 -> 打印 3
// 再处理事件队列 -> 打印 4
}
// 输出:
// 1
// 2
// 3
// 4
Future(() => ...) 的回调进入事件队列,而 scheduleMicrotask 的回调进入微任务队列。虽然两者都是”异步”的(在 main 同步代码之后执行),但微任务先于事件队列任务执行。
这段代码对应的队列变化可以用下图表示:
微任务队列的存在使得 Dart 可以在处理完当前事件后、处理下一个事件前,优先完成一些”短小且紧急”的工作。日常开发中,你很少需要手动调度微任务,但理解它的存在对于排查 Future 执行顺序问题很重要。
1.3 async 函数的执行机制
上一篇已经介绍了 async/await 的基本用法,这里补充其底层执行机制。
官方文档明确指出:async 函数不会立即等待耗时操作完成,它只会执行到遇到的第一个 await 表达式,然后返回一个 Future。 只有当 await 表达式完成后,函数才会恢复执行。
1
2
3
4
5
6
7
8
9
10
11
12
13
Future<void> doWork() async {
print('A - await 之前'); // 同步执行
final data = await fetch(); // 遇到 await,函数返回 Future
print('B - await 之后'); // fetch 完成后才执行
final result = await parse(data); // 再次 await
print('C - 完成');
}
Future<String> fetch() async {
// 模拟网络请求
await Future.delayed(Duration(seconds: 1));
return 'raw data';
}
执行过程:
- 调用
doWork(),同步执行到print('A') - 遇到
await fetch(),doWork暂停,返回一个未完成的Future<void> fetch()内部遇到await Future.delayed(...),同样暂停并返回 Future- Dart 运行时将延迟任务交给底层处理,事件循环可以处理其他事件(如 UI 重绘、用户点击)
- 1 秒后延迟完成,事件循环恢复
fetch()执行,返回'raw data' doWork()恢复执行,data拿到'raw data',继续执行到下一个 await
关键点:await 期间,Dart 代码暂停,当前 async 函数挂起,但不阻塞 Isolate 的执行流,事件循环可以继续处理其他事件。底层原生代码(如文件 I/O、网络请求)在操作系统层面执行,不占用 Dart 的事件循环。完成后将结果作为事件放入队列,事件循环再恢复对应的 async 函数。
顺带提一个常见的对应关系:Android 中延迟执行用 handler.postDelayed({ ... }, 1000),Dart 中用 Future.delayed(Duration(seconds: 1), () { ... }),后者同样是往事件队列扔一个延迟事件。
1.4 Zone – 异步代码的执行上下文
前面讲了事件循环和两个队列,但还有一个与异步调用链密切相关的概念:Zone。所有 Dart 代码都在某个 Zone 中运行。Zone 可以保存只在当前上下文中可见的值,拦截 print、Timer、微任务调度等操作,还能为一组同步和异步调用建立统一的未处理错误边界。它不隔离内存或权限,因此不应把它理解成安全沙箱。
默认情况下,Dart 代码运行在 root zone 中。需要同时捕获函数体中的同步异常和未处理的异步异常时,应使用 runZonedGuarded():
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
import 'dart:async';
void main() {
runZonedGuarded(() {
throw Exception('Zone 内的异常');
}, (error, stackTrace) {
print('捕获到错误: $error');
});
print('main 继续');
}
// 输出:
// 捕获到错误: Exception: Zone 内的异常
// main 继续
旧代码中经常能看到 runZoned(..., onError: ...),但 onError 参数已经弃用,官方建议改用 runZonedGuarded()。Zone 只能接住进入其错误边界、且没有被其他代码处理的错误;Future 的错误传播还会受到 error zone 边界影响。因此它不是给任意异步代码套上的“全局 try-catch”,未处理错误最终如何表现也取决于运行平台和上层框架。
Zone 还有一个重要特性:在某个 Zone 中注册的异步回调,之后执行时仍会回到注册它的 Zone。Future.then()、Timer 等回调即使被事件循环排到后面,也保留创建时的 Zone 上下文。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
void main() {
runZonedGuarded(() {
Timer(Duration(seconds: 1), () {
throw Exception('定时器中的异常');
});
}, (error, stackTrace) {
print('1秒后捕获: $error');
});
print('main 继续执行,不阻塞');
}
// 输出:
// main 继续执行,不阻塞
// 1秒后捕获: Exception: 定时器中的异常
Timer 回调在子 Zone 中注册,触发时仍运行在该 Zone 中,所以未处理异常会交给它的错误处理函数。
Zone 还支持更细粒度的定制,通过 ZoneSpecification 可以拦截 print、定时器、微任务调度等底层操作:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
void main() {
runZoned(() {
print('hello');
Future(() => print('world'));
}, zoneSpecification: ZoneSpecification(
// 拦截 print 调用
print: (self, parent, zone, line) {
parent.print(zone, '[Zone] $line');
},
));
}
// 输出:
// [Zone] hello
// [Zone] world
日常业务代码很少需要直接创建 Zone,但调试工具、测试框架和错误上报库会利用它保存上下文或拦截异步行为。Flutter 的错误处理则分为不同入口:build、layout、paint 等框架回调中的异常会交给 FlutterError.onError;不在 Flutter 回调栈中的未处理错误可以交给 PlatformDispatcher.instance.onError。Zone 可能参与异步上下文和错误边界,但这两套 Flutter 错误处理机制不能简单归结为“Flutter 用 Zone 包住整个应用”。
与 Kotlin 对比,Zone 更接近“异步上下文 + 行为拦截 + 错误边界”的组合。Kotlin 中可以借助 CoroutineContext 保存协程上下文,通过 CoroutineExceptionHandler 处理特定协程范围内的未捕获异常;Java 的 Thread.setDefaultUncaughtExceptionHandler 只提供线程级未捕获异常处理,能力和作用域都不等同于 Zone。
二、Isolate – Dart 的并发方案
2.1 为什么不用线程
现代设备都有多核 CPU。很多语言通过共享内存的多线程来利用多核,但共享状态并发容易出错:需要锁、信号量来保护共享数据,稍有不慎就会产生数据竞争(Data Race)和死锁。
Dart 采用了 Actor 风格的隔离模型。所有 Dart 代码都运行在 isolate 中,每个 isolate 有自己的状态和事件循环。业务代码不能直接访问另一个 isolate 的可变对象,只能通过消息协调工作。
这种隔离避免了共享可变内存带来的锁和数据竞争:一个 isolate 不能直接修改另一个 isolate 正在读取的普通可变对象。不过,“隔离”不等于所有底层内存都必然复制。Dart VM 可以共享 String 等不可变对象,也提供 TransferableTypedData 和 Isolate.exit() 等转移数据所有权的机制;这些优化不会改变 isolate 之间不能直接共享可变状态的编程模型。
isolate 也不能消除所有竞态条件。如果多个 isolate 通过消息竞争外部资源,或者业务结果依赖消息到达顺序,仍然可能出现逻辑层面的竞态。它消除的是普通共享可变内存上的数据竞争,不是所有并发时序问题。
与 Kotlin 对比:Kotlin/JVM 协程允许访问共享内存,同一个对象可以被不同线程上的协程读写,所以一旦共享可变状态,就需要 Mutex、Atomic、线程限制或 Actor/Channel 等方式约束并发访问。Dart 的 isolate 更接近 Actor 模式,但隔离边界更硬:业务代码拿不到另一个 isolate 的可变对象引用。
在 Android 中,你在子线程修改 UI 会报 CalledFromWrongThreadException。在 Dart/Flutter 中,worker isolate 无法直接访问主 isolate 的 Widget 树和状态对象,所以通常不是“跨线程改 UI 报错”,而是根本拿不到那些 UI 对象;需要把结果发回主 isolate,再由主 isolate 更新界面。
2.2 Isolate 的生命周期
每个 isolate 的生命周期遵循一个简单的过程:
- 运行初始 Dart 代码(通常是
main()函数) - 初始代码可能注册事件监听器(响应用户输入、文件 I/O 等)
- 初始函数返回后,如果还有事件需要处理,isolate 继续存活
- 处理完所有事件后,isolate 退出
以 main isolate 为例:程序启动时执行 main(),main() 执行完毕后,isolate 并不会立即退出,而是进入事件循环,等待和处理事件。UI 重绘、用户点击等事件都在 main() 退出之后才被处理。
如果一个同步操作耗时过长,事件队列中后续的事件就会被延迟处理。在客户端应用中,这表现为界面卡顿、动画掉帧,甚至完全无响应。这就是为什么耗时计算应该放到 worker isolate 中执行。
2.3 Isolate 不是线程
如果你从 Kotlin 等支持多线程的语言来到 Dart,可能会把 isolate 类比成线程,但官方文档明确强调:Isolate 不是线程。
核心区别在于内存隔离。每个 isolate 拥有独立的全局字段,一个 isolate 中的状态对其他 isolate 完全不可见。举个例子:如果应用中有一个全局可变变量,在 spawned isolate 中修改它,主 isolate 中的同名变量不会受到任何影响,因为它们是各自 isolate 中的独立副本。
这与 Kotlin/JVM 的线程和协程不同。Kotlin 允许多个线程或协程访问同一个对象,而 Dart isolate 的业务通信必须显式通过消息完成。Dart VM 是否在底层共享不可变对象、复制普通对象或转移特定数据,不会向业务代码暴露跨 isolate 的可变对象引用。
2.4 Isolate Group 与性能
当 isolate 通过 Isolate.spawn() 创建新 isolate 时,两个 isolate 属于同一个 isolate group。同一 isolate group 内的 isolate 共享可执行代码,因此新 isolate 可以立即运行,启动开销较小。
Isolate.exit() 方法也只允许在同一 isolate group 内使用,它用于将结果直接发送回主 isolate 并终止 worker isolate。
某些特殊场景下可能需要 Isolate.spawnUri(),它从指定 URI 加载代码来创建新 isolate。但官方文档指出:spawnUri() 比 spawn() 慢得多,且新 isolate 不在调用者的 isolate group 中,消息传递也更慢。
消息传递的成本要分情况看。同一 isolate group 内通过 SendPort.send() 发送消息时,String 等不可变对象可以共享,普通可变对象通常会复制,因此发送一个很大的对象图可能有线性时间开销。需要转移大块字节数据时可以使用 TransferableTypedData。Isolate.run() 返回最终结果时使用 Isolate.exit(),由于发送方随即退出,这个最终结果可以直接转移给调用方而不复制。
Web Worker 的默认消息传递使用 structured clone,但也支持通过 transferable objects 转移 ArrayBuffer 等资源。因此,Web Worker 与 isolate 的差异不在于“一个只能复制、一个可以转移”,而在于启动方式、可发送类型、运行时集成和具体转移 API 都不同。
把 main isolate、worker isolate、事件队列和消息端口放在一起看,大致是下面这个关系:
三、使用 Isolate
3.1 Isolate.run() – 一次性任务
Isolate.run() 是最简单的 isolate 使用方式,它封装了完整的 worker isolate 生命周期:
- 创建并启动 isolate
- 在新 isolate 上运行函数
- 捕获结果
- 将结果返回给主 isolate
- 工作完成后终止 isolate
- 检查并捕获异常,抛回主 isolate
以下示例用 Isolate.run() 在 worker isolate 中解析 JSON:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
import 'dart:convert';
import 'dart:io';
import 'dart:isolate';
const String filename = 'with_keys.json';
void main() async {
final jsonData = await Isolate.run(_readAndParseJson);
print('Number of JSON keys: ${jsonData.length}');
}
Future<Map<String, dynamic>> _readAndParseJson() async {
final fileData = await File(filename).readAsString();
final jsonData = jsonDecode(fileData) as Map<String, dynamic>;
return jsonData;
}
也可以直接传闭包,写法更像”并行执行”的控制流操作符:
1
2
3
4
5
6
7
8
void main() async {
final jsonData = await Isolate.run(() async {
final fileData = await File(filename).readAsString();
final jsonData = jsonDecode(fileData) as Map<String, dynamic>;
return jsonData;
});
print('Number of JSON keys: ${jsonData.length}');
}
两种写法效果相同。Isolate.run() 的返回值始终是 Future,因为主 isolate 的代码会继续执行,不会阻塞等待 worker isolate 完成。无论 worker 中执行的计算是同步还是异步,对主 isolate 来说都是并发执行的。
如果你使用 Flutter,可以直接用 compute() 函数,它是对 Isolate.run() 的封装,用法更简单,在后面的 3.4 节会详细介绍。
3.2 ReceivePort 与 SendPort
Isolate.run() 适用于一次性任务:spawn -> 计算 -> 返回 -> 退出。但如果需要反复执行相同计算,频繁 spawn 新 isolate 的开销不可忽视。这时需要创建长期存活的 isolate,通过端口进行双向通信。
ReceivePort 和 SendPort 是 isolate 间通信的唯一方式:
- ReceivePort:接收其他 isolate 发来的消息。它实现了
Stream,可以像流一样listen。 - SendPort:向对应的 ReceivePort 发送消息,类似
StreamController。
一个 SendPort 对应一个 ReceivePort,但一个 ReceivePort 可以有多个 SendPort。创建 ReceivePort 时会自动创建一个关联的 SendPort。
建立双向通信的过程:
- 主 isolate 创建 ReceivePort,将它的 SendPort 作为参数传给 worker isolate
- worker isolate 启动时收到这个 SendPort,创建自己的 ReceivePort
- worker isolate 通过收到的 SendPort 把自己的 SendPort 回传给主 isolate
- 双方各自 hold 对方的 SendPort,双向通道建立完成
这和 Kotlin 的 Channel 概念有些相似:双方都通过通道传递数据,而不是让业务代码随意读写对方状态。区别是一个 ReceivePort 表示单向接收端,它对应的 SendPort 可以复制并传给多个发送方;要建立双向通信,需要两组方向相反的端口。消息中的普通可变对象通常会被复制,接收方拿到的不是发送方可继续修改的同一个对象引用。
3.3 长期通信:Isolate.spawn() + Ports
下面是一个完整的长期通信示例。主 isolate 创建一个 Worker,可以反复发送 JSON 字符串给 worker isolate 解析,worker 解析后回传结果。
为了处理多个并发请求的正确匹配,每条消息附带一个唯一 ID,配合 Completer 实现请求-响应对应:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
import 'dart:async';
import 'dart:convert';
import 'dart:isolate';
class Worker {
final SendPort _commands; // 向 worker 发送消息的端口
final ReceivePort _responses; // 接收 worker 回复的端口
final Map<int, Completer<Object?>> _activeRequests = {};
int _idCounter = 0;
bool _closed = false;
Worker._(this._responses, this._commands) {
_responses.listen(_handleResponsesFromIsolate);
}
// 创建 Worker 实例,spawn worker isolate 并建立双向通信
static Future<Worker> spawn() async {
final initPort = RawReceivePort();
final connection = Completer<(ReceivePort, SendPort)>.sync();
initPort.handler = (initialMessage) {
final commandPort = initialMessage as SendPort;
connection.complete((
ReceivePort.fromRawReceivePort(initPort),
commandPort,
));
};
try {
await Isolate.spawn(_startRemoteIsolate, initPort.sendPort);
} on Object {
initPort.close();
rethrow;
}
final (ReceivePort receivePort, SendPort sendPort) =
await connection.future;
return Worker._(receivePort, sendPort);
}
// worker isolate 入口
static void _startRemoteIsolate(SendPort sendPort) {
final receivePort = ReceivePort();
sendPort.send(receivePort.sendPort);
_handleCommandsToIsolate(receivePort, sendPort);
}
// worker isolate 中处理来自主 isolate 的消息
static void _handleCommandsToIsolate(
ReceivePort receivePort,
SendPort sendPort,
) {
receivePort.listen((message) {
if (message == 'shutdown') {
receivePort.close();
return;
}
final (int id, String jsonText) = message as (int, String);
try {
final jsonData = jsonDecode(jsonText);
sendPort.send((id, jsonData));
} catch (e, stackTrace) {
sendPort.send((id, RemoteError(e.toString(), stackTrace.toString())));
}
});
}
// 主 isolate 中处理来自 worker 的回复
void _handleResponsesFromIsolate(dynamic message) {
final (int id, Object? response) = message as (int, Object?);
final completer = _activeRequests.remove(id);
if (completer == null) return;
if (response is RemoteError) {
completer.completeError(response);
} else {
completer.complete(response);
}
// close() 可能在仍有请求等待响应时调用。
// 最后一个请求结束后再关闭接收端口,避免端口泄漏。
if (_closed && _activeRequests.isEmpty) {
_responses.close();
}
}
// 公开方法:发送 JSON 字符串给 worker 解析
Future<Object?> parseJson(String message) async {
if (_closed) throw StateError('Closed');
final completer = Completer<Object?>.sync();
final id = _idCounter++;
_activeRequests[id] = completer;
_commands.send((id, message));
return await completer.future;
}
// 关闭端口,释放资源
void close() {
if (!_closed) {
_closed = true;
_commands.send('shutdown');
if (_activeRequests.isEmpty) _responses.close();
}
}
}
使用方式:
1
2
3
4
5
6
7
8
9
10
11
void main() async {
final worker = await Worker.spawn();
final result1 = await worker.parseJson('{"name": "Dart", "version": 3}');
print(result1); // {name: Dart, version: 3}
final result2 = await worker.parseJson('{"a": 1, "b": 2}');
print(result2); // {a: 1, b: 2}
worker.close();
}
这个 Worker 类的几个关键设计点:
- RawReceivePort:用于接收 worker 的初始消息(SendPort),之后再转为普通 ReceivePort 以添加 listen 回调。普通 ReceivePort 只能有一个 listener,而 RawReceivePort 允许先处理初始消息再转换。
- 消息 ID + Completer:每条消息附带唯一 ID,worker 回复时也带上 ID。主 isolate 用
Map<int, Completer>保存待完成的请求,收到回复后根据 ID 找到对应的 Completer 并完成。这解决了多个并发请求时响应顺序不确定的问题。 - shutdown 消息:通过特殊消息通知 worker 关闭命令端口。如果关闭时仍有请求在处理,主 isolate 会等最后一个响应到达后再关闭自己的 ReceivePort,避免端口一直存活导致程序无法退出。示例省略了 worker 意外退出、请求超时和主动取消等生产级处理,实际项目还需要按业务补充。
3.4 何时使用 Isolate
官方文档没有给出“必须使用 isolate”的硬性阈值,但列了一些适合放到后台 isolate 的场景:
- 解析和解码特别大的 JSON 数据
- 处理和压缩图片、音频、视频
- 转换音频和视频文件
- 对大型列表或文件系统执行复杂的搜索和过滤
- 执行会同步阻塞当前 isolate 的数据库、文件或 FFI 操作
- 在大量网络请求之后做较重的解码、解压、加密或聚合计算
核心判断标准不是“只要 I/O 就 async,只要 CPU 就 isolate”,而是:这段工作会不会让当前 isolate 长时间无法处理下一个事件。Flutter 中还要看它是否会超过帧预算,造成动画掉帧或交互卡顿。
结合 Android 经验,可以分成几类看:
场景 A:原生异步 I/O 等待
网络请求、文件读取这类 API 如果本身已经返回 Future,通常直接 async/await 即可,不需要为了“等待”新建 isolate。运行时和操作系统会处理底层异步流程;对 Dart 代码来说,重点是当前 isolate 没有被同步卡住。
1
final data = await file.readAsString();
场景 B:小规模 CPU 计算
少量 JSON 解析、简单列表转换、小数据量加密等操作,直接在当前 isolate 执行通常更划算。创建 isolate 也有启动和消息传递成本,任务太小反而可能得不偿失。
场景 C:会阻塞事件循环的重计算或同步阻塞操作
大图滤镜、复杂算法、超大 JSON 解析、同步 FFI 调用、耗时数据库任务等,如果会让 UI isolate 几十毫秒甚至更久不能处理事件,就应该考虑 worker isolate。否则主 Event Loop 被卡住,Flutter 端就会表现为掉帧或无响应。
如果你使用 Flutter,推荐直接用 Flutter 封装好的 compute() 函数,它是对 Isolate.run() 的简易包装,会自动创建子 Isolate、计算完自动销毁并返回结果:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
import 'package:flutter/foundation.dart';
// 使用顶层函数可以避免闭包意外捕获不必要的状态
int heavyCalculation(int count) {
var result = 0;
for (var i = 0; i < count; i++) {
result += i;
}
return result;
}
// 2. 调用 compute,自动创建子 Isolate 执行
void main() async {
final result = await compute(heavyCalculation, 1000000);
print('结果: $result');
}
compute() 并没有强制回调必须是顶层函数或静态函数。Native 平台上的 callback、message 和 result 只要都能通过 isolate 发送即可,闭包也可以使用。但闭包可能意外捕获当前作用域中的其他对象,既增加复制和内存开销,也可能因为捕获了 Socket、打开的文件等不可发送对象而在运行时失败。因此,顶层函数、静态函数或只捕获必要数据的轻量闭包更稳妥。
平台行为也要区分:
- Flutter Native:
compute(callback, message)等价于Isolate.run(() => callback(message)),回调在新的 isolate 中执行。 - Flutter Web:
compute()在当前事件循环上执行,不会自动创建 Web Worker,也不能提供多核并行。如果 Web 端确实需要后台线程,需要单独编写和接入 Web Worker。
注意事项:
- Native 平台上,callback、message 和 result 都必须是可发送对象
- isolate 消息有少数类型限制,如
Socket、ReceivePort、DynamicLibrary、Pointer等包含原生资源的对象不能发送 - 标记了
@pragma('vm:isolate-unsendable')的类实例也不能发送
四、异步编程进阶
4.1 Future API
上一篇介绍了 async/await 语法,它是处理 Future 的推荐方式。但在某些场景下(尤其是需要链式回调时),直接使用 Future API 更直观。
Future API 的三个核心方法:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
Future<String> fetchName() async {
await Future.delayed(Duration(seconds: 1));
return 'Dart';
}
void main() {
fetchName()
.then((name) {
print('成功: $name');
return name.toUpperCase();
})
.catchError((error) {
print('失败: $error');
})
.whenComplete(() {
print('无论成功失败都执行');
});
}
// 1秒后输出:
// 成功: Dart
// 无论成功失败都执行
三个方法的对应关系:
| Future API | async/await 对应 |
|---|---|
then((value) => ...) | final value = await future; 后续代码 |
catchError((error) => ...) | try { ... } catch (e) { ... } |
whenComplete(() => ...) | try { ... } finally { ... } |
来看一个具体对比,同样是异步读文件并 trim 结果,两种写法:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 写法一:async/await
Future<String> _readFileAsync() async {
final file = File(filename);
final contents = await file.readAsString();
return contents.trim();
}
// 写法二:Future API(then 链)
Future<String> _readFileAsync(String filename) {
final file = File(filename);
return file.readAsString().then((contents) {
return contents.trim();
});
}
两种写法功能完全等价,最终都返回一个 Future<String>。区别在于:
| 维度 | async/await(写法一) | Future API(写法二) |
|---|---|---|
| async 标记 | 函数标记为 async,return 值自动包装为 Future | 无 async 标记,直接返回已有的 Future |
| 执行方式 | await 暂停函数,等 Future 完成后恢复执行后续代码 | then 注册回调,函数立即返回链式 Future |
| 错误处理 | try/catch/finally | .catchError() / .whenComplete() |
| 代码风格 | 接近同步代码,线性阅读 | 链式回调,适合简单的数据转换管道 |
写法一中,await 让代码看起来像同步的:读文件、拿到结果、trim、返回,一目了然。写法二中,readAsString() 返回一个 Future,.then() 注册回调,函数本身不”暂停”,直接把这个链式 Future 返回给调用者。
有一个容易忽略的细节:async 函数中,即使 return 的是一个普通值(如 return contents.trim() 返回 String),Dart 也会自动将其包装为 Future<String>。而写法二没有 async 标记,必须确保函数返回的是 Future – 这里 then() 的返回值本身就是 Future,所以没问题。
async/await 本质上是 then 链的语法糖,编译器会将 async/await 代码转换为等价的 Future 链式调用。选择哪种取决于代码结构:简单的顺序异步用 async/await 更清晰,多层链式转换用 Future API 更紧凑。
Future() 与 Future.value() 的执行顺序差异
理解了事件队列和微任务队列之后,来看一个容易让人困惑的问题:Future() 和 Future.value() 创建的 Future,执行顺序不同。
1
2
3
4
5
6
7
8
void main() {
print('1');
Future(() => print('A')); // ① 事件队列
Future.value(() => print('B')); // ② 这里其实有坑,见下文
print('2');
}
先说结论:Future() 的回调进入事件队列,而 Future.value() 创建的是一个已完成的 Future,后续通过 .then() 注册的回调进入微任务队列。所以微任务先执行。
但上面的代码有个陷阱:Future.value() 接收的参数是值本身,不是函数。Future.value(() => print('B')) 会把整个闭包作为值存下来,不会执行 print。正确的对比写法应该是:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
void main() {
print('1');
Future(() => print('A')); // ① 回调进入事件队列
Future.value(null).then((_) => print('B')); // ② .then 回调进入微任务队列
print('2');
}
// 输出:
// 1
// 2
// B
// A
B 先于 A 执行。原因:
Future(() => ...)内部使用Timer.run()将回调排入事件队列Future.value(v)创建一个已经完成的 Future,当你在它上面调用.then(cb)时,Dart 不会立即执行 cb,而是将 cb 作为微任务排入微任务队列- 事件循环的规则是先清空微任务队列,再处理事件队列,所以 B 先于 A
同理,Future.microtask() 也会将回调排入微任务队列:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
void main() {
print('1');
Future(() => print('A')); // 事件队列
Future.microtask(() => print('B')); // 微任务队列
print('2');
}
// 输出:
// 1
// 2
// B
// A
总结三种 Future 构造方式对应的队列:
| 构造方式 | 回调进入的队列 | 执行优先级 |
|---|---|---|
Future(() => ...) | 事件队列 | 低 |
Future.value(v).then(cb) | 微任务队列 | 高 |
Future.microtask(() => ...) | 微任务队列 | 高 |
这个差异在日常开发中很少需要关心,但当你遇到多个 Future 执行顺序不符合预期时,排查思路就是检查它们分别进入了哪个队列。
4.2 Completer
Completer<T> 允许你手动控制一个 Future 的完成。正常情况下,async 函数在 return 时自动完成 Future;但当你需要在事件回调中完成 Future 时,就需要 Completer。
基本用法:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
import 'dart:async';
void main() {
final completer = Completer<String>();
// 模拟异步操作完成后调用 complete
Future.delayed(Duration(seconds: 1), () {
completer.complete('完成!');
});
completer.future.then((value) => print(value));
}
// 1秒后输出: 完成!
completer.complete(value) 完成 Future 并设置返回值,completer.completeError(error) 以错误完成 Future,completer.future 获取被控制的 Future。
在前面的 isolate Worker 示例中,Completer 发挥了关键作用:每次调用 parseJson() 时创建一个 Completer,将其 Future 返回给调用者。当 worker isolate 的响应到达时,通过 ID 找到对应的 Completer 并 complete,调用者的 await 随即返回。
在 Kotlin 中,类似的概念是 CompletableDeferred,同样用于手动控制异步结果的完成。
4.3 单订阅流与广播流
Dart 官方按监听方式把 Stream 分成两类:单订阅流(single-subscription stream)和广播流(broadcast stream)。它们和 Kotlin 中的冷流、热流有相似之处,但语义并不完全相同,不能直接画等号。
单订阅流在整个生命周期中只能有一个监听者。async* 函数返回的就是单订阅流,并且只有开始监听后,函数体才会执行:
1
2
3
4
5
6
7
8
9
10
11
12
Stream<int> countTo(int max) async* {
for (var i = 1; i <= max; i++) {
yield i;
}
}
void main() async {
final stream = countTo(3);
await for (final value in stream) {
print(value);
}
}
这里要区分“调用生成器函数”和“监听同一个 Stream”:每次调用 countTo(3) 都会创建一个新的单订阅 Stream,但同一个 Stream 不能重复 listen,即使第一次订阅已经取消也不行。
如果多个观察者需要同时接收同一数据源的事件,可以使用广播流。创建广播流有两种常见方式。
方式一:StreamController.broadcast()
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
import 'dart:async';
void main() async {
final controller = StreamController<int>.broadcast();
controller.stream.listen((x) => print('监听器 A: $x'));
controller.stream.listen((x) => print('监听器 B: $x'));
controller.add(1);
controller.add(2);
await controller.close();
}
// 输出:
// 监听器 A: 1
// 监听器 B: 1
// 监听器 A: 2
// 监听器 B: 2
StreamController.broadcast() 允许多个监听者。事件发送时,当前处于监听状态的订阅者都会收到它;没有监听者时发送的事件不会留给后来的订阅者。
方式二:asBroadcastStream()
已有单订阅 Stream 时,可以调用 asBroadcastStream() 在它之上创建广播流:
1
2
3
4
5
6
7
void main() {
final source = Stream.periodic(Duration(seconds: 1), (i) => i);
final broadcast = source.asBroadcastStream();
broadcast.listen((x) => print('A: $x'));
broadcast.listen((x) => print('B: $x'));
}
两类 Stream 的核心区别如下:
| 特性 | 单订阅流 | 广播流 |
|---|---|---|
| 监听者数量 | 整个生命周期只能有一个 | 可以有多个 |
| 开始产生事件 | 通常在监听后开始 | 事件源可以独立于监听者产生事件 |
| 后加入的监听者 | 不适用,不能再次监听同一个 Stream | 只能收到加入之后的事件 |
| 常见用途 | 文件读取、async* 生成的数据序列 | UI 事件、传感器事件、状态变化通知 |
与 Kotlin 比较时,可以把 async* 创建的单订阅 Stream 粗略类比为 Flow,把广播流粗略类比为 SharedFlow。但 Kotlin 的 SharedFlow 支持 replay、额外缓冲区和溢出策略,Dart 的普通广播流默认不保存历史事件。StateFlow 还会保留当前状态,Dart 标准库没有与之完全等价的 Stream 类型,需要自行维护当前值,或者使用状态管理库。
4.4 理解 yield
上一篇介绍 Stream 时已经用到了 yield:在 async* 函数中,yield 向 Stream 产出一个值。这里深入理解它的工作机制。
yield 不是 return。return 会终止函数并返回最终结果;yield 产出当前值后,函数暂停而非终止,等待消费者读取下一个值时再恢复执行。这种”产出一个值就挂起”的机制,正是 async* 单订阅流按需产出数据的基础。
来看一个更完整的例子:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
import 'dart:async';
Stream<int> naturalsTo(int n) async* {
int k = 1;
while (k <= n) {
yield k; // 产出 k,函数在此暂停
k++; // 消费者读取下一个值时,从这里恢复执行
}
}
void main() async {
await for (final value in naturalsTo(3)) {
print(value);
}
}
// 输出:
// 1
// 2
// 3
执行过程:
naturalsTo(3)被调用,返回一个 Stream(但函数体尚未执行)await for开始消费,触发函数体执行k = 1,遇到yield 1,产出值 1,函数暂停- 消费者打印 1,请求下一个值
- 函数恢复执行,
k++变为 2,yield 2产出值 2,再次暂停 - 重复直到循环结束,Stream 关闭
还有一个 yield* 语法,用于产出另一个 Stream 的所有值(而非单个值):
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
Stream<int> expandedNaturals(int n) async* {
yield* naturalsTo(n); // 展开 naturalsTo(n) 的所有值
yield* naturalsTo(n); // 再展开一遍
}
void main() async {
await for (final v in expandedNaturals(2)) {
print(v);
}
}
// 输出:
// 1
// 2
// 1
// 2
yield* 会把后面的 Stream 的所有值逐个转发给外层 Stream,相当于”展平”嵌套流。
与 Kotlin yield 的对比
Dart 和 Kotlin 都有 yield,但语义有重要区别:
| 维度 | Dart yield | Kotlin yield |
|---|---|---|
| 适用场景 | async* 生成器函数中,向 Stream 产出值 | sequence 生成器中向消费者产出值;协程中用于让出执行权(不产出值) |
| 挂起机制 | 产出值后函数暂停,消费者拉取下一个值时恢复 | sequence 中挂起并产出值;协程中挂起让出调度权,不产出值 |
| 产出多个值 | yield value 产出单个,yield* stream 展平另一个 Stream | yield(value) 产出单个,yieldAll(sequence) 展平另一个 Sequence |
| 类型标记 | async* 标记函数,返回 Stream<T> | sequence { } 构建器返回 Sequence<T> |
对应的 Kotlin 代码:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// Kotlin: sequence + yield(产出值)
fun naturalsTo(n: Int): Sequence<Int> = sequence {
var k = 1
while (k <= n) {
yield(k) // 产出 k,挂起
k++
}
}
fun main() {
naturalsTo(3).forEach { println(it) }
}
// Kotlin: Flow + emit (Flow 中用 emit 而非 yield)
suspend fun naturalsFlow(n: Int): Flow<Int> = flow {
var k = 1
while (k <= n) {
emit(k) // 产出 k,挂起
k++
}
}
注意 Kotlin 中的一个细节:yield 用于 sequence 生成器,而 Flow 中用的是 emit。Dart 中不区分这两者,统一用 yield。对比表:
| 操作 | Dart | Kotlin Sequence | Kotlin Flow |
|---|---|---|---|
| 产出单个值 | yield value | yield(value) | emit(value) |
| 展平另一个流 | yield* stream | yieldAll(sequence) | emitAll(flow) |
| 生成器标记 | async* | sequence { } | flow { } |
Kotlin 协程中的 yield()
除了 sequence 生成器,Kotlin 协程中还有一个无参的 yield(),它的语义完全不同:不产出任何值,只是挂起当前协程、让出执行权给调度器,允许其他协程运行。这是协作式多任务的手动让点:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// Kotlin: 协程中的 yield(),让出执行权
suspend fun task(id: String) {
for (i in 1..3) {
println("$id: $i")
yield() // 挂起,让其他协程有机会执行
}
}
fun main() = runBlocking {
launch { task("A") }
launch { task("B") }
}
// 可能的输出(交替执行):
// A: 1
// B: 1
// A: 2
// B: 2
// A: 3
// B: 3
这个 yield() 和 Dart 的 yield 没有对应关系。它既不产出值,也不是 Stream 生成器语法,而是 Kotlin 协程调度器中的协作式让点。
Dart 里也可以通过 await Future(() {})、Timer.run 等方式把后续工作排到之后的事件中,让事件循环先处理别的事件。但这不是 Dart yield 的语义,也不要用微任务来类比 Kotlin yield():微任务优先级高,连续调度微任务反而可能让事件队列饥饿。
Dart 的 yield 专注于一个职责:在 async* 函数中向 Stream 产出值,并在必要时等待订阅端继续接收。Kotlin 的 yield 根据上下文有两种语义:在 sequence 中产出值,在协程中让出调度权。名字相同,模型不同,写文章时要把这两层分开。
五、总结
Dart 的并发体系有两层:
- async/await + Future/Stream:运行在单个 isolate 的事件循环上,通过事件队列和微任务队列调度。适合表达异步等待、事件流和原生异步 I/O,不会因为等待结果而阻塞事件循环。
- Isolate:真正的多核并行执行单元,每个 isolate 有独立状态和事件循环,通过消息传递通信。适用于会长时间占用当前 isolate 的工作,例如超大 JSON 解析、图片处理、复杂算法、同步阻塞调用等。
选择标准可以简化成一句话:等待外部结果,用 Future/Stream;当前 isolate 自己要连续算很久,才考虑 Isolate 或 Flutter Native 上的 compute()。
与 Kotlin 的对比总结:
| 维度 | Dart | Kotlin |
|---|---|---|
| 异步机制 | 事件循环 + async/await | 协程 + suspend |
| 并发模型 | Isolate(隔离状态,消息传递) | 线程/协程(可共享内存,也可用 Channel/Actor 约束共享) |
| 线程安全 | 普通可变对象不能跨 isolate 共享;仍需处理消息顺序等逻辑竞态 | 共享可变状态需要 Mutex/Atomic/线程限制等机制 |
| 异步执行上下文 | Zone(异步上下文、行为拦截、错误边界) | CoroutineContext / CoroutineExceptionHandler / 线程异常处理等分别承担部分职责 |
| 通道通信 | SendPort/ReceivePort | Channel |
| 手动控制 Future | Completer | CompletableDeferred |
| 产出值并挂起 | yield / yield*(async* 中) | yield(Sequence)/ emit(Flow) |
| 冷流 | Stream(单订阅) | Flow |
| 热流 | Broadcast Stream(默认不重放历史事件) | SharedFlow/StateFlow |
对于有 Kotlin 基础的开发者来说,Dart 异步的语法层面并不陌生(async/await、Stream 都有对应概念)。真正需要适应的是 isolate 的隔离模型:不能把对象引用直接交给另一个执行单元继续改,只能通过消息传递协调。这样少了很多共享内存锁的问题,但并不意味着并发逻辑天然正确,消息顺序、取消、超时、资源释放仍然要认真处理。




