【从一道面试题入手】深入理解 Dart 事件循环调度机制

0 阅读5分钟

引言

前两天在整理 Flutter 面试题时,遇到这么一道经典题:

Future<void> main() async {
  print('1');
  Future.microtask(() => print('2'));
  Future(() => print('3'));
  await Future(() => print('4'));
  print('5');
  Future.microtask(() => print('6'));
  print('7');
}

问:输出顺序是什么?

很多人第一反应是 1, 2, 3, 4, 5, 6, 7,也有人猜 1, 3, 2, 4, 5, 7, 6。但真正的答案是:

1
2
3
4
5
7
6

为什么是这个顺序?为什么 7 在 6 前面?这背后藏着 Dart 事件循环(Event Loop)的核心调度哲学。本文就从这道题出发,把 Dart 的事件循环机制彻底讲透。


一、为什么需要事件循环?

在讲机制之前,先想一个问题:Dart 是单线程语言,它怎么处理异步任务?

iOS 开发者可能会想到 RunLoop,前端同学会想到浏览器的 Event Loop,Java 开发者会想到 Looper。没错,单线程语言处理异步的核心思想都是事件循环。

Dart 的事件循环做的事情很简单:

while (true) {
  // 1. 先把 microtask 队列清空
  while (microtaskQueue.isNotEmpty) {
    microtaskQueue.removeFirst().run();
  }
  
  // 2. 再处理一个 event
  if (eventQueue.isNotEmpty) {
    eventQueue.removeFirst().run();
  }
}

注意两个关键点:

  • microtask 队列优先级更高,每轮循环都要清空
  • event 队列每轮只取一个处理,处理完后回到第一步重新检查 microtask

二、两个队列:Microtask vs Event

特性Microtask QueueEvent Queue
优先级高(每轮清空)低(每轮取 1 个)
来源Future.microtask()、scheduleMicrotask()、await 的 continuationFuture()、Timer、I/O、手势事件
设计目的短任务,需要在当前事件之后立即执行常规异步任务、I/O、定时器

类比 iOS 生态

DartiOS 对标
Event LoopCFRunLoop
Microtask QueuekCFRunLoopBeforeSources 阶段的 perform block
Event QueueSource0 / Source1 事件源
await continuationdispatch_async 到主队列的 block

三、逐行拆解面试题

我们把执行过程分成三个阶段来看。

阶段 1:main() 同步执行

print('1');                                    // 同步输出 1
Future.microtask(() => print('2'));            // 加入 microtask 队列
Future(() => print('3'));                      // 加入 event 队列
await Future(() => print('4'));                // 加入 event 队列,main 挂起

执行到 await 时,main 函数挂起,把控制权交还给事件循环。

此时队列状态:

  • microtask 队列:[print2]
  • event 队列:[print3, print4]

阶段 2:事件循环接管

第 1 轮循环:

① 清空 microtask → 执行 print2 → 输出 2
② 取一个 event  → 执行 print3 → 输出 3

第 2 轮循环:

① microtask 空,跳过
② 取一个 event  → 执行 print4 → 输出 4
                 → Future4 完成,await 的 continuation 加入 microtask

第 3 轮循环:

① 清空 microtask → 执行 continuation(恢复 main 函数)
   ├─ print('5')          → 输出 5
   ├─ Future.microtask(print6)  → 加入 microtask 队列
   └─ print('7')          → 输出 7
   
   microtask 队列还有 print6,继续执行 → 输出 6

这就解释了为什么 7 在 6 前面:print6 是在 continuation 执行期间才被加入 microtask 队列的,要等当前 microtask(也就是 print5~print7 这段)执行完才能轮到它。


四、流程图直观展示

ioio.png


五、await 的本质

很多人把 await 理解成"等待",但更准确的理解是:挂起当前函数,把后续代码包装成一个 microtask,然后让出控制权。

// 这段代码
Future<void> foo() async {
  await bar();
  doSomething();
}

// 大致等价于
Future<void> foo() {
  return bar().then((_) {
    doSomething();
  });
}

then 注册的回调,本质上就是一个 microtask。所以 await 之后的代码,会在 Future 完成后的下一轮 microtask 清空阶段执行。


六、在 Flutter 中的实际意义

理解事件循环,能帮我们解决很多实际问题:

1. 为什么 setState 后 UI 不会立即更新?

setState(() {
  _count = 1;
});
print(_count); // 这里 _count 已经是 1 了,但 UI 还没刷新

setState 只是标记了"需要重建",真正的重建是在当前事件循环结束后的下一帧(由 VSync 信号驱动的 event)。所以在同一个 microtask 或同步代码中,多次 setState 只会触发一次重建。

2. 为什么 microtask 不能滥用?

// 危险写法:大量 microtask 会阻塞 event 处理
for (var i = 0; i < 10000; i++) {
  Future.microtask(() => heavyCompute());
}

因为每轮事件循环要清空所有 microtask 才会处理 event,如果你往 microtask 队列里塞了大量耗时任务,event 队列里的手势、绘制、定时器都会被饿死,最终表现就是掉帧、无响应。

3. 如何安全地"插队"执行?

// 场景:在 build 完成后做一些事情
WidgetsBinding.instance.addPostFrameCallback((_) {
  // 这个回调是在帧绘制完成后触发的 event
  // 不会阻塞当前帧的渲染
});

addPostFrameCallback 用的是 event 队列而非 microtask,所以不会影响当前帧。


七、总结

回到那道面试题,记住三句话就能秒杀:

  1. 同步代码先行:所有同步的 print 先执行完
  2. microtask 插队:每轮事件循环先清空 microtask,再碰 event
  3. await 让出 + 回调入 microtask:await 之后的代码要等 Future 完成后,通过 microtask 恢复执行

最终输出:

1  → 同步
2  → microtask(第 1 轮清空)
3  → event(第 1 轮取 1 个)
4  → event(第 2 轮取 1 个),完成后 continuation 入 microtask
5  → continuation 中同步执行
7  → continuation 中同步执行
6  → 第 3 轮继续清空 microtask

写在最后

事件循环是 Dart 异步编程的基石,理解它不只是为了应付面试,更是为了写出高性能、不卡顿的 Flutter 应用。当你遇到"为什么我的 UI 不刷新"、"为什么这个回调没执行"、"为什么页面掉帧"时,回到事件循环的模型去思考,往往能找到答案。

如果你觉得这篇文章对你有帮助,欢迎点赞 👍 收藏 ⭐,有问题也欢迎在评论区交流~


参考资料: