口述:Dart 异步体系全景——从事件循环到协程协作
一句话概括
Dart 的异步体系由 Event Loop、Future/async-await、Stream、Isolate 四大组件构成,形成了一套从”单线程事件调度”到”跨 Actor 消息并行”的完整并发方案,让开发者用同步风格写异步代码的同时,无需担心死锁和数据竞争。
背景与意义
如果你从未写过 Dart,只知道它是一个用来写 Flutter 的语言,那你对它异步体系的第一印象可能是:”哦,就是 Promise 和 async/await 那一套吧。” 但如果你真的开始写 Flutter 应用,你很快就会碰到一些 Python 或 Java 背景的人不太容易理解的概念——比如”为什么 await 之后一定要检查 mounted”、”为什么 setState 在异步回调里会抛异常”、”为什么我的大数据解析会让 UI 卡死”、”为什么 Dart 里没有 synchronized 关键字”。
这些问题背后的答案,都得从 Dart 的异步体系设计哲学说起。
Dart 团队在设计异步系统时,面临一个经典的权衡:安全 vs 性能 vs 开发效率。他们给出的选择非常特别——单线程事件循环(像 JavaScript)+ 隔离堆的 Actor 模型(像 Erlang)+ 既能同步又能异步的 Zone。这套组合让 Dart 在跨端开发的上下文中,有了相当独特的定位。
四个核心组件
我把 Dart 的异步体系拆成四个层次来理解:
第一层:Event Loop —— 心脏
Dart 的每一个 Isolate 都自带一个事件循环。这个循环只有一件事:不断从两个队列里取任务执行。
第一个队列是微任务队列(Microtask Queue),优先级最高。Future 的 then 回调、async 函数恢复点、scheduleMicrotask 的注册任务,都在这里排队。
第二个队列是事件队列(Event Queue),优先级稍低。来自外部的 I/O 完成、定时器到期、用户交互事件,都在这里等候。
一个 tick 的过程是这样的:执行完当前所有微任务 → 取一个事件执行 → 回到微任务检查。这意味着微任务可以”无限插队”事件任务——如果你在微任务中又注册了微任务,事件队列将被无限期推迟。
这个模型决定了你写的 Dart 代码的执行顺序。理解它是理解一切异步 Bug 的起点。
1
2
3
4
5
6
7
8
9
10
11
void main() {
Future(() => print('event')); // 事件队列
Future.microtask(() => print('micro')); // 微任务队列
scheduleMicrotask(() {
print('micro2');
scheduleMicrotask(() => print('micro3'));
});
print('sync');
}
输出顺序:sync → micro → micro2 → micro3 → event
为什么?同步代码永远最先执行,然后事件循环开始运行:清空微任务队列(micro、micro2、micro3),然后从事件队列取一个事件(event)。
第二层:Future + async/await —— 协程调度器
很多人以为 async/await 就是”异步代码写起来像同步”。但这个表述掩盖了一个关键事实:await 不止是”等待”——它是挂起。
当你写 await future 时,当前函数被”暂停”了。不是阻塞线程,而是函数的状态被保存到一个状态机里,控制权交还给事件循环。当 future 完成时,事件循环通过微任务把函数恢复回执行状态。
这意味着在 await 和 await 之间的这段时间,事件循环可以处理其他任何事情——用户点击、动画帧、其他异步回调。这就是 Dart 能在单线程上处理成千上万并发请求的原因,虽然它一次只能做一个事。
1
2
3
4
5
6
7
8
9
10
11
12
Future<String> downloadData(String id) async {
print('开始下载 $id');
final result = await http.get(Uri.parse('https://api.example.com/data/$id'));
// ↑ 这里挂起了,事件循环可以处理其他任务
final parsed = await _parseData(result.body);
// ↑ 再次挂起
print('下载完成 $id');
return parsed;
}
这个函数内部有三个状态:初始状态(打印开始)、等待网络响应、等待数据解析。每个 await 之间,事件循环都不被阻塞。
但有一个重要的注意点:await 挂起的是当前函数,不是当前Isolate。如果你在一个 async 函数里做了 CPU 密集型计算(比如解析 100MB 的 JSON),那事件循环依然会被阻塞,因为计算是在同一个线程上运行的。这时候就需要用到第四层——Isolate。
第三层:Stream —— 事件序列
如果说 Future 是”一次性的异步交付”,那 Stream 就是”持续性的异步交付”。
Stream 的强大不在于它多复杂,而在于它让你可以用声明式的方式处理数据流。你不再写”当数据到达时做什么”的回调,而是描述”当数据流经这个管道时如何变换”的管线。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// 命令式回调风格
socket.listen((data) {
final decoded = utf8.decode(data);
final lines = decoded.split('\n');
for (final line in lines) {
if (line.startsWith('ERROR')) {
logger.error(line);
}
}
});
// 声明式流式风格
socket.stream
.transform(utf8.decoder)
.transform(LineSplitter())
.where((line) => line.startsWith('ERROR'))
.listen(logger.error);
第二种风格的优雅之处在于:变换是可组合的、可复用的、可测试的。而且 Stream 的自然背靠背事件循环调度机制,使得它天生适合处理像 WebSocket、数据库变更订阅、用户输入事件这类”不确定何时结束”的数据源。
第四层:Isolate —— 真·并行
这是 Dart 区别于 JavaScript 的最大特征。JavaScript 的事件循环也在一个线程上运行,但 JavaScript 没有 Isolate。如果你想在 JS 中并行,你需要 Web Worker——而 Web Worker 不是语言特性,是浏览器 API。
Dart 的 Isolate 是语言本身的并行原语。每个 Isolate 拥有独立的事件循环和堆,通过消息传递通信。
为什么 Dart 选择了”隔离堆”加”消息传递”,而不是”共享内存”加”锁”?有几个原因:
第一,安全。异步系统 + 共享内存 + 锁 ≈ 各种数据竞争、死锁、ABA 问题。隔离内存从根上消除了这些。
第二,调试简单。当你看到一个 Isolate 里的状态异常时,你可以确信不是别的 Isolate 改的。
第三,适用跨端。移动设备的内存通常是受限的,隔离堆的 GC 可以独立运行,不会触发 Stop-The-World 暂停所有 Isolate。
但 Isolate 也有它的代价:创建开销大(数毫秒,数 MB 堆空间)、消息传递需要序列化/反序列化、无法共享复杂对象。这迫使开发者仔细斟酌什么场景真的需要 Isolate——它是解决并行计算问题的重武器,不是解决异步问题的常用工具。
四个组件的协作方式
这四个组件不是独立的,它们在运行时紧密协作:
1
2
3
4
5
6
7
8
9
User Input(事件队列)
→ async handler(微任务调度)
→ Future(Completer 完成)
→ 微任务恢复 async 函数
→ 计算结果(长时间任务通过 SendPort 发往)
→ 工作 Isolate(另一个事件循环)
→ 计算结果传回(通过 ReceivePort)
→ 恢复主 Isolate 的 await
→ 更新 UI
一个具体的例子:用户点击”加载年报”按钮。
- 点击事件进入主 Isolate 的事件队列
- 事件循环取出事件,执行
onPressed回调 - 回调中调用
await fetchReportData(),这个 async 函数立即开始执行 - 代码遇到
await http.get(url),函数挂起,控制权回到事件循环 - 网络 I/O 由 Dart 的 IO 系统异步处理(底层可能用线程池读取socket)
- 响应到达后,
http.get的 Future 完成,恢复函数(通过微任务) - 如果数据很大(比如 50MB JSON),把 JSON 字符串通过
Isolate.spawn或compute发到工作 Isolate - 工作 Isolate 解析 JSON,把结果发回
- 主 Isolate 收到结果,更新状态,调用 setState 刷新 UI
整个过程中,主事件循环从未阻塞。用户即使在加载期间也能滑动列表、点击其他按钮。
写给你的建议
如果你在写 Flutter 业务代码,用好第一层到第三层就够了。Event Loop 的理解能帮你调试异步执行顺序;Future 和 async/await 是你每天写的最多代码;Stream 是状态管理库(Bloc、Riverpod、Rxdart)的底座。
只有当你遇到以下场景时,才应该考虑 Isolate:
- 解析大 JSON(> 5MB)
- 图片压缩/处理
- 加密/解密
- 复杂的列表排序/过滤(数据量级 10 万以上)
- 任何在主线程上超过 16ms 的同步计算
容易踩的坑
1. await 在 for 循环里
1
2
3
for (final item in items) {
await process(item); // 串行执行!每个等上一个完成
}
这不是”并行处理”,这仅仅是”顺序执行 + 非阻塞等待”。正确方式是用 Future.wait:
1
await Future.wait(items.map(process));
2. 微任务无限注册
1
2
3
4
Future.microtask(() {
print('loop');
Future.microtask(() => /* 再注册微任务 */);
});
微任务中再注册微任务会导致事件队列永远不被处理——事件循环必须等微任务队列清空后才会取事件。
3. Isolate 中 UI 相关的代码
永远记住:Isolate 中不能访问 Flutter 的 UI 对象(Widget、BuildContext、MediaQuery 等)。如果你在 compute 函数里传了一个 Widget,编译不会报错,但运行时一定会崩。
4. Stream 忘记取消订阅
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
class MyWidget extends StatefulWidget {
@override
State<MyWidget> createState() => _MyWidgetState();
}
class _MyWidgetState extends State<MyWidget> {
StreamSubscription? _subscription;
@override
void initState() {
super.initState();
_subscription = someStream.listen((data) {
setState(() => _data = data);
});
}
@override
void dispose() {
_subscription?.cancel(); // 这一步忘了就是内存泄漏!
super.dispose();
}
}
Stream 的监听会一直持有回调引用,直到你取消订阅或 Stream 关闭。在 dispose 中取消是强制性的。
面试题自测
- 解释
Future(() => 1)和Future.value(1)的执行顺序差异(事件队列 vs 微任务) - 如何让三个独立 API 请求并行执行后统一处理结果(Future.wait 或 StreamGroup)
- 为什么
compute函数的参数必须是顶级函数(顶层不让闭包捕获主 Isolate 的内存) - 如果有一个”无限循环”的 Stream 生成器,如何在 listener 中优雅地退出循环(用 break 退出 await for,或用 cancel 取消订阅)
- 同步 Stream 和异步 Stream 的区别是什么(sync: true 时事件在当前微任务中同步触发,更高效但容易造成递归栈溢出)
总结
Dart 的异步体系不是一座孤岛——它有自己的设计哲学:安全优先于灵话,隔离优先于性能。Event Loop 提供了非阻塞的基础设施,Future/Stream 提供了声明式的编程模型,Isolate 提供了真正的并行能力。这三者加上 Zone(另一个深入的话题),构成了 Dart 完整的异步生态。
当你理解了这四个层次和它们的协作关系,大部分 Flutter 中的”奇怪异步行为”就不再是问题——你会在看到错误信息前就预判到问题可能出在哪里。
扩展阅读
- Dart 官方异步文档
- Dart 并发白皮书
- Flutter 渲染管线中的异步调度
- 推荐阅读:《Dart 编程语言(原书第2版)》第6章——并发