单线程为什么不卡:事件循环与微任务
「不能干重活」对,但「切线程」——没得切。Dart 只有一根线程,没有 Dispatchers.IO,await 也不换线程。它靠的是一台事件循环:干活的时候一根筋,等待的时候(网络、磁盘、定时器)把控制权交出去。理解这台循环,你就懂了 Flutter 为什么流畅、以及它卡的时候为什么卡。
一根线程,两个队列
Dart 代码跑在一个叫 isolate 的东西里(第 20 章细讲),一个 isolate 只有一根线程。这根线程干完当前这段同步代码后,会去两个队列里找下一件事做:
Future.then 的回调、scheduleMicrotask 进这里。必须全部排空才轮到下面。Future(...)、Timer、I/O 完成、用户点击、渲染下一帧都进这里。微任务队列必须完全排空,才允许从事件队列取下一个。
这一句话能解释 Dart 异步全部的「诡异」行为——包括那道著名的输出顺序题。
亲手跑:事件循环模拟器
下面这台真的按上面的规则调度。点「单步」,一步步看它从哪个队列取、输出什么;两个队列的当前内容实时显示在上面。
场景一:那道经典的 1 4 3 2
为什么不是 1 2 3 4,也不是 1 4 2 3?单步走一遍就清楚了:
- 1、4:
main里的同步代码,立刻执行。中间那两句只是往队列里塞了任务,没执行。 main跑完,去排空微任务队列 → 输出 3(Future.microtask在这里)。- 微任务空了,才从事件队列取一个 → 输出 2(
Future(...)在这里)。
记住两个进队规则就够了:Future(() => ...) 和 Timer 进事件队列;Future.then 的回调、Future.microtask 进微任务队列。微任务永远插在下一个事件之前。
场景二:async 函数在第一个 await 之前是同步的
这个反直觉,但极其重要。看输出是 A B C D——调用 load() 时,它的函数体立刻同步执行(打印 B),直到撞上第一个 await 才「切出去」,把剩下的部分(打印 D)排进队列,然后控制权回到 main(打印 C)。
调用一个 async 函数,不等于「把它丢到后台」。它当场就在这根线程上开始跑,一直跑到第一个 await。await 做的不是「切到别的线程」,而是「我要等一个 Future,在等的期间,把线程让给别人用;等的东西好了,我的后半段作为一个任务被排回队列」。
这和 Kotlin 的 suspend 惊人地像——那边遇到真正的挂起点也是先同步执行、再让出。区别只在:Kotlin 让出后可能被调度到另一个线程恢复,Dart 永远在同一根线程恢复。
场景三:一个死循环怎么冻住整个 App
这是「主线程不能干重活」的真正含义。看那句「该画下一帧了」——它只能等你那个同步循环算完。因为:
只要一段同步代码还在跑,这根线程上什么都插不进来——微任务插不进、事件插不进、渲染下一帧(它也在事件队列里!)也插不进。
所以 Flutter 掉帧/卡死最常见的成因,不是绘制慢,是有人在这根线程上同步干了重活:同步解析一个大 JSON、同步跑一个复杂计算、同步读一个大文件。界面在这段时间里完全冻结——连触摸都没反应,因为触摸事件也在排队。
解法不是「切线程」(没有线程可切),而是分两种:
- 如果重活是等待型的(网络、磁盘 I/O):用
await——等待期间线程是空闲的,界面照常刷新。这是最常见的情况,下一章讲。 - 如果重活是计算型的(真的在烧 CPU:图像处理、大数据解析、加密):
await救不了它(计算没有「等待」可让出),必须把它扔到另一个 isolate——用Isolate.run(第 20 章)。
场景四:微任务饥饿
最后一个场景演示微任务的危险面。一个微任务里不断排新的微任务——微任务队列永远排不空,于是事件队列一次都轮不到,包括渲染帧。界面直接冻住,即使你没写任何死循环。
99% 的情况你想要的是 Future(() => ...)(进事件队列),不是 scheduleMicrotask(进微任务队列)。微任务的设计目的很窄——「在控制权交还给事件循环之前必须完成的一小段收尾」。把普通任务放进微任务,轻则插队打乱顺序,重则像场景四那样饿死渲染。日常异步,用 Future 和 async/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) 不是「立刻执行」,而是「当前这轮忙完、微任务清空后,尽快执行」——一个把重活拆成小块、给界面留出喘息的常用技巧就基于此。
你要卸载的最大直觉:Dispatchers.IO / withContext。在 Android 里你习惯「重活切到 IO 线程池,主线程只碰 UI」。Dart 没有这套——只有一根线程。
| Kotlin 协程 | Dart |
|---|---|
| 多线程(主 + IO + Default 线程池) | 单线程 + 事件循环 |
withContext(Dispatchers.IO) 切线程 | await 让出线程(不切) |
CPU 密集也用 Dispatchers.Default | CPU 密集必须开新 isolate(第 20 章) |
| 挂起点可能换线程恢复 | 永远同线程恢复 |
好消息:因为单线程、无共享内存并发,Dart 里几乎没有数据竞争、没有锁、没有 @Volatile、没有 synchronized——你在 Android 里为线程安全操的那些心,这里大半不用操了。代价是 CPU 重活要显式开 isolate,但那是少数场景。用「让出」替换「切换」这一个概念,你就跟上了 Dart 的并发模型。
这一章的一句话
Dart 只有一根线程,靠事件循环不卡界面:微任务队列排空才轮到事件队列取下一个(这就是 1 4 3 2);async 函数在第一个 await 前是同步的,await 是「让出线程」而非「切线程」;而任何超过一帧预算的同步代码都会冻住整个 App——包括渲染本身。
规则懂了,下一章看具体工具:Future 怎么用、它和 Kotlin 的 suspend 差在哪(有两个坑)、Stream 怎么对应 Flow(也有两个坑)。这些是你写每一个网络请求都会碰到的东西。