卷 V · 异步CH 18深度 18/24

单线程为什么不卡:事件循环与微任务

直觉过境 「主线程不能干重活,要切到 IO 线程。」 改一下

「不能干重活」对,但「切线程」——没得切。Dart 只有一根线程,没有 Dispatchers.IOawait 也不换线程。它靠的是一台事件循环:干活的时候一根筋,等待的时候(网络、磁盘、定时器)把控制权交出去。理解这台循环,你就懂了 Flutter 为什么流畅、以及它卡的时候为什么卡。

事件循环微任务队列单线程不可抢占

一根线程,两个队列

Dart 代码跑在一个叫 isolate 的东西里(第 20 章细讲),一个 isolate 只有一根线程。这根线程干完当前这段同步代码后,会去两个队列里找下一件事做:

微任务队列 MICROTASK
优先级高。Future.then 的回调、scheduleMicrotask 进这里。必须全部排空才轮到下面。
事件队列 EVENT
优先级低。Future(...)Timer、I/O 完成、用户点击、渲染下一帧都进这里。
规则
干完同步代码 → 排空整个微任务队列 → 从事件队列取一个 → 再排空微任务 → 再取一个……
◆ 事件循环的唯一规则

微任务队列必须完全排空,才允许从事件队列取下一个。

这一句话能解释 Dart 异步全部的「诡异」行为——包括那道著名的输出顺序题。

亲手跑:事件循环模拟器

下面这台真的按上面的规则调度。点「单步」,一步步看它从哪个队列取、输出什么;两个队列的当前内容实时显示在上面。

场景一:那道经典的 1 4 3 2

为什么不是 1 2 3 4,也不是 1 4 2 3?单步走一遍就清楚了:

  1. 1、4main 里的同步代码,立刻执行。中间那两句只是往队列里塞了任务,没执行。
  2. main 跑完,去排空微任务队列 → 输出 3Future.microtask 在这里)。
  3. 微任务空了,才从事件队列取一个 → 输出 2Future(...) 在这里)。

记住两个进队规则就够了:Future(() => ...)Timer 进事件队列;Future.then 的回调、Future.microtask 进微任务队列。微任务永远插在下一个事件之前。

场景二:async 函数在第一个 await 之前是同步的

这个反直觉,但极其重要。看输出是 A B C D——调用 load() 时,它的函数体立刻同步执行(打印 B),直到撞上第一个 await 才「切出去」,把剩下的部分(打印 D)排进队列,然后控制权回到 main(打印 C)。

◆ 破除一个大误解

调用一个 async 函数,不等于「把它丢到后台」。它当场就在这根线程上开始跑,一直跑到第一个 awaitawait 做的不是「切到别的线程」,而是「我要等一个 Future,在等的期间,把线程让给别人用;等的东西好了,我的后半段作为一个任务被排回队列」。

这和 Kotlin 的 suspend 惊人地像——那边遇到真正的挂起点也是先同步执行、再让出。区别只在:Kotlin 让出后可能被调度到另一个线程恢复,Dart 永远在同一根线程恢复。

场景三:一个死循环怎么冻住整个 App

这是「主线程不能干重活」的真正含义。看那句「该画下一帧了」——它只能等你那个同步循环算完。因为:

◆ 同步代码不可被抢占

只要一段同步代码还在跑,这根线程上什么都插不进来——微任务插不进、事件插不进、渲染下一帧(它也在事件队列里!)也插不进。

所以 Flutter 掉帧/卡死最常见的成因,不是绘制慢,是有人在这根线程上同步干了重活:同步解析一个大 JSON、同步跑一个复杂计算、同步读一个大文件。界面在这段时间里完全冻结——连触摸都没反应,因为触摸事件也在排队。

解法不是「切线程」(没有线程可切),而是分两种:

  • 如果重活是等待型的(网络、磁盘 I/O):用 await——等待期间线程是空闲的,界面照常刷新。这是最常见的情况,下一章讲。
  • 如果重活是计算型的(真的在烧 CPU:图像处理、大数据解析、加密):await 救不了它(计算没有「等待」可让出),必须把它扔到另一个 isolate——用 Isolate.run(第 20 章)。

场景四:微任务饥饿

最后一个场景演示微任务的危险面。一个微任务里不断排新的微任务——微任务队列永远排不空,于是事件队列一次都轮不到,包括渲染帧。界面直接冻住,即使你没写任何死循环。

⚠ 慎用 scheduleMicrotask

99% 的情况你想要的是 Future(() => ...)(进事件队列),不是 scheduleMicrotask(进微任务队列)。微任务的设计目的很窄——「在控制权交还给事件循环之前必须完成的一小段收尾」。把普通任务放进微任务,轻则插队打乱顺序,重则像场景四那样饿死渲染。日常异步,用 Futureasync/await 就对了。

那 60fps 是怎么保住的

把前面的图拼完整:Flutter 引擎在每个 vsync(约每 16.7ms)往事件队列里塞一个「渲染下一帧」的任务。只要你的每一段同步代码都短于这个预算,渲染任务就能准时被取到、界面就流畅。

反过来,卡顿的判据也清楚了:任何一段同步代码(一个 build、一个回调、一次布局)只要超过 16ms,就会挤掉那一帧的渲染,用户就看到一次掉帧。DevTools 的 Performance 页把每一帧画成一根柱子,超预算的标红——排查卡顿就是找那些红柱子里的同步长任务。

为什么「异步」和「并行」是两回事

这是从多线程语言过来最需要掰正的一个概念。你可能觉得「异步 = 后台跑」,但在 Dart 里:

◆ 异步 ≠ 并行

异步(async)是「时间上的交错」——一根线程在等 A 的空档里去干 B,A 好了再回来。全程只有一根线程,任一时刻只在干一件事,只是巧妙地填满了等待的空隙。

并行(parallel)是「空间上的同时」——真的有两个核,两件事字面意义上同时在算。这在 Dart 里要靠 isolate(第 20 章)。

Dart 的事件循环给你的是异步:一根线程,靠让出和排队,把「等待」的时间榨干。它没有给你并行——那要另开 isolate。你 App 里 95% 的「异步」其实都只是异步、不是并行,而且这完全够用,因为绝大多数活是在等 I/O,不是在烧 CPU。

这解释了一个常见误解:「我用了 async/await,为什么还卡?」——因为 await 只帮你利用等待的空隙,它对正在烧 CPU 的同步计算无能为力(场景三)。异步救 I/O,并行救计算,别拿错药。

Future.delayed 和 Timer:定时的两种写法

顺带认全事件队列里最常见的两个来源,你会经常用:

// 延迟执行一次
Timer(const Duration(seconds: 3), () => hideBanner());

// 周期执行(记得存起来、在 dispose 里 cancel——第 9 章对称律)
_timer = Timer.periodic(const Duration(seconds: 1), (t) => _tick());

// await 一段时间(在 async 函数里更常用)
await Future.delayed(const Duration(milliseconds: 500));
doSomething();

它们都往事件队列塞任务,所以它们的回调永远排在当前同步代码和所有微任务之后(这一章的规则)。这也意味着 Timer(Duration.zero, cb) 不是「立刻执行」,而是「当前这轮忙完、微任务清空后,尽快执行」——一个把重活拆成小块、给界面留出喘息的常用技巧就基于此。

⇄ Compose 对照 · 从「切线程」到「让出线程」

你要卸载的最大直觉:Dispatchers.IO / withContext在 Android 里你习惯「重活切到 IO 线程池,主线程只碰 UI」。Dart 没有这套——只有一根线程。

Kotlin 协程Dart
多线程(主 + IO + Default 线程池)单线程 + 事件循环
withContext(Dispatchers.IO) 切线程await 让出线程(不切)
CPU 密集也用 Dispatchers.DefaultCPU 密集必须开新 isolate(第 20 章)
挂起点可能换线程恢复永远同线程恢复

好消息:因为单线程、无共享内存并发,Dart 里几乎没有数据竞争、没有锁、没有 @Volatile、没有 synchronized——你在 Android 里为线程安全操的那些心,这里大半不用操了。代价是 CPU 重活要显式开 isolate,但那是少数场景。用「让出」替换「切换」这一个概念,你就跟上了 Dart 的并发模型。

这一章的一句话

Dart 只有一根线程,靠事件循环不卡界面:微任务队列排空才轮到事件队列取下一个(这就是 1 4 3 2);async 函数在第一个 await 前是同步的,await 是「让出线程」而非「切线程」;而任何超过一帧预算的同步代码都会冻住整个 App——包括渲染本身。

规则懂了,下一章看具体工具:Future 怎么用、它和 Kotlin 的 suspend 差在哪(有两个坑)、Stream 怎么对应 Flow(也有两个坑)。这些是你写每一个网络请求都会碰到的东西。