cubegao

Dart Event Loop、Future 与 Isolate 深度解析

2022-02-26

一、一个隐藏在数据库操作中的 UI 卡顿

企业 IM 的会话列表,需要先展示本地数据库中已有的 2000+ 条会话记录,再静默同步服务端增量数据。我们最初的实现是同步读取数据库:

1
2
3
4
5
List<Conversation> loadConversations() {
final db = Database.open(conversationDbPath);
final rows = db.query('SELECT * FROM conversations');
return rows.map(parseConversation).toList();
}

在 Profile 模式下,这段代码耗时约 300ms。问题是它跑在主 Isolate 的 UI 线程上——300ms 内 Platform 层收不到新帧的提交,用户看到的就是会话列表卡住不动。

排查出这个问题后,我们把数据库操作移到后台 Isolate。结果列表不卡了,但新的问题来了:数据在后台 Isolate 处理好、发回主 Isolate 后,setStatesetState() called after dispose()——因为用户在操作加载中退出了会话页,后台任务回来时 Widget 已经销毁了。

这个案例涉及三个 Dart 核心概念:Event Loop(事件循环)、Future/async-await(异步调度)、Isolate(并发隔离)。本文逐一展开。

二、Event Loop:Dart 的心跳

2.1 单线程模型的设计哲学

Dart 采用单线程事件循环模型,和 JavaScript 一脉相承。Dart 团队选择单线程的核心理由有三:

  1. 避免竞态条件:不存在「两个线程同时修改同一个变量」的问题,开发者不需要处理锁、信号量、原子操作。
  2. 符合 UI 框架需求:Flutter 的 Widget/Element/RenderObject 树都是非线程安全的,多线程操作这些树会导致不可预料的崩溃。单线程模型天然保护了框架内部状态。
  3. 异步非阻塞:通过 Event Loop + Future 的组合,单线程同样能处理高并发 I/O,不需要为每个连接开一个线程。

代价是:任何长时间同步计算都会阻塞 Event Loop,冻结 UI。 这迫使开发者将耗时操作交给 Future 或 Isolate。

2.2 两个队列:Microtask Queue 和 Event Queue

Dart 的 Event Loop 维护两个队列,按严格的优先级顺序消费:

1
2
3
4
5
6
7
8
9
10
11
12
13
Microtask Queue(优先级高)

├── 连续清空所有 Microtask


Event Queue(优先级低)

├── 消费一个 Event

│ → 执行过程中产生的新 Microtask 插入 Microtask Queue
│ → 回到顶部,重新清空 Microtask Queue

└── 清空 Microtask 后,消费下一个 Event ... 循环

Microtask Queue:用于必须「尽快在当前 Event 完成后、下一个 Event 开始前」运行的短任务。通过 scheduleMicrotask() 添加。

Event Queue:所有 I/O 事件、Timer 回调、Gesture 事件、Future.then() 回调都会进入这个队列。这是 Dart 异步的主力军。

2.3 一个容易写错的例子

1
2
3
4
5
6
7
8
9
10
11
12
13
14
void main() {
Future(() => print('Event 1'));
Future.microtask(() => print('Microtask 1'));
Timer.run(() => print('Event 2'));
scheduleMicrotask(() => print('Microtask 2'));
print('main');
}

// 输出顺序:
// main
// Microtask 1
// Microtask 2
// Event 1
// Event 2

理解这个顺序的关键:print('main') 在同步代码路径中直接执行。Microtask 先于 Event 执行。Timer.runFuture 的默认构造函数都进入 Event Queue。

三、Future 和 async-await 的调度机制

3.1 Future 的三种执行时机

1
2
3
4
5
6
7
8
9
10
11
// 1. 立即计算,回调进入 Event Queue
Future.value(42).then((v) => print(v));

// 2. 同步代码中创建,回调进入 Event Queue
Future(() => heavyComputation());

// 3. 延迟执行
Future.delayed(Duration(seconds: 1), () => ...);

// 4. Microtask
Future.microtask(() => ...);

需要注意:Future.value() 的值计算是同步的,但 .then() 的回调永远是异步的——它会进入 Microtask Queue(如果 Future 已经完成)或等待 Future 完成后进入 Microtask Queue。

3.2 async-await 的底层等价转换

1
2
3
4
5
6
7
8
9
10
11
12
// await 写法
Future<void> syncMessages() async {
final msgs = await api.fetchMessages();
database.insertAll(msgs);
}

// 等价于 .then() 链
Future<void> syncMessages() {
return api.fetchMessages().then((msgs) {
return database.insertAll(msgs);
});
}

await 之后的代码被拆分为一个 .then() 回调,放入 Microtask Queue 挂在当前 Future 上。这意味着 await 之后的代码不会在当前同步调用栈中执行。

3.3 业务陷阱:微任务堆积导致的优先级倒挂

在企业 IM 的消息同步流程中,我们曾错误地在循环中使用 await

1
2
3
4
5
// 错误:串行处理,每条消息 await 一次
for (final msg in newMessages) {
await database.insert(msg); // 每个 await 产生一个微任务
await _notifyListeners(msg.id); // 又一个微任务
}

100 条新消息产生 200 个挂起的 Future 链,虽不会阻塞 Event Loop(因为每个回调执行很快),但使得其他高优先级的微任务(如渲染相关的微任务)排在这 200 个之后。正确的做法是批量插入,减少 Future 链的深度:

1
2
await database.batchInsert(newMessages); // 一条 SQL,一个 Future
_notifyListeners(newMessages.map((m) => m.id));

四、Isolate:Dart 的并发模型

4.1 Isolate 是什么

Isolate 是 Dart 的并发单元。每个 Isolate 拥有独立的 Event Loop、独立的堆(Heap)、独立的运行状态。Isolate 之间不共享内存——这是与 Java Thread 的根本区别。

1
2
3
4
5
6
7
8
9
// 创建一个 Isolate
final receivePort = ReceivePort();
await Isolate.spawn(backgroundTask, receivePort.sendPort);

void backgroundTask(SendPort sendPort) {
// 完全独立的内存空间,看不到主 Isolate 的任何变量
final result = heavyComputation();
sendPort.send(result);
}

不共享内存意味着:没有锁、没有死锁、没有数据竞争。代价是跨 Isolate 通信只能通过消息传递(SendPort/ReceivePort),消息必须可序列化。

4.2 什么时候用 Isolate

在企业 IM 中,Isolate 的典型使用场景包括:

数据库操作:会话列表从 SQLite 加载 2000+ 条记录,包含多表 JOIN 的查询耗时可达 200-500ms。这个时间如果跑在主 Isolate,UI 冻结半秒。

JSON 解析:服务端返回的增量数据可能是 200+ KB 的 Protobuf/JSON,解析耗时 50-100ms。对于高端机型来说不算长,但中低端设备上可能导致掉帧。

图片解码前的尺寸计算:聊天气泡需要提前计算图片素材的宽高比。上千张缩略图的尺寸计算不应阻塞 UI。

不应该用 Isolate 的场景:轻量计算、频繁的小通信(Isolate 间的消息传递有序列化开销)、需要频繁访问 UI 状态的操作。

4.3 Compute 函数的便捷封装

Flutter 提供了 compute 函数作为 Isolate 的简化用法:

1
final result = await compute(parseProtoBuf, rawBytes);

compute 自动管理 ReceivePort 的生命周期。但它每次调用都会创建和销毁一个新的 Isolate,适用于一次性的重计算任务。对于需要持久运行的场景(如持续监听消息通道),应直接使用 Isolate.spawn 并保持 Isolate 存活。

4.4 Isolate 通信的业务模式

在企业 IM 的消息预加载功能中,我们使用了一个常驻的「数据库 Isolate」:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
class DatabaseIsolate {
late Isolate _isolate;
late SendPort _sendPort;
final _receivePort = ReceivePort();

Future<void> init() async {
_isolate = await Isolate.spawn(_run, _receivePort.sendPort);
final response = await _receivePort.first;
_sendPort = response as SendPort;
}

Future<List<Conversation>> loadConversations() {
final resultPort = ReceivePort();
_sendPort.send(['load_conversations', resultPort.sendPort]);
return resultPort.first as Future<List<Conversation>>;
}
}

关键设计是用 ReceivePort 作为返回值通道,每次请求创建一次性的 ReceivePort,确保返回结果精准路由到对应的调用方。同时用 Completer 将消息回调桥接到 Future,让上层代码能以 await 方式使用。

五、总结

Dart 的异步与并发模型,本质上是一条精心设计的折中路线:

  • Event Loop:单线程 + 双队列(Microtask/Event),在保证 UI 安全的前提下实现异步非阻塞。
  • Future/async-await:将回调地狱转换为顺序可读的代码,底层仍是微任务调度。
  • Isolate:通过不共享内存的并发单元,在需要时突破单线程瓶颈,代价是消息传递的序列化开销。

在企业 IM 的实际开发中,最常犯的错误不是用错了 API,而是把耗时操作留在了 Event Queue 里,而不是推到另一个 Isolate 中。Event Loop 只是调度器,不是算力来源——当一个 Future 的回调本身就要 200ms,Event Loop 唯一能做的就是等到它执行完,期间 UI 完全冻结。理解这一点,是做出正确异步决策的起点。

扫描二维码,分享此文章