在 Flutter(以及底层的 Dart)中,异步编程的核心并不是“多线程”,而是事件循环(Event Loop)

理解这个机制就像理解一个单排队的咖啡店:店员(线程)只有一个,但通过巧妙的任务安排,他可以一边等咖啡机出水,一边帮下一位客人点单。


1. 核心架构:Event Loop 机制

Dart 是单线程执行模式。这意味着它在同一时间只能执行一行代码。为了处理耗时操作(如网络请求或定时器)而不卡住 UI,它维护了两个队列:

  • Microtask Queue(微任务队列): 优先级极高。通常用于非常短的、需要在返回任务调度之前立即执行的操作。

  • Event Queue(事件队列): 存放外部事件,如 I/O、手势点击、定时器、绘图事件以及 Future

运行流程

  1. 首先执行 main() 函数中的同步代码。

  2. 检查 Microtask Queue。只要里面有任务,就一直执行,直到清空。

  3. Event Queue 中取出一个事件并执行。

  4. 每执行完一个事件任务,都会回到 Microtask Queue 检查一遍。

  5. 循环往复。


2. Future 与 async/await 的真相

很多初学者认为 Future 是开启了新线程,其实不然。

  • Future 当你创建一个 Future 时,你实际上是把一个任务丢进了 Event Queue

  • async/await 这只是语法糖。await 会暂时“挂起”当前的函数,将剩余的代码包装成一个匿名函数并放入队列,然后释放执行权,让 Event Loop 去处理其他事情。

示例对比

Dart

void test() async {
  print("A"); // 同步执行
  await Future(() => print("B")); // 进入 Event Queue
  print("C"); // 只有等 B 执行完,这行才会回到微任务/事件循环中执行
}
// 输出顺序:A -> (其他同步代码) -> B -> C

3. 为什么 UI 会卡顿?

由于是单线程,如果你在同步代码里写了一个巨大的循环:

Dart

// 坏代码:这会导致 UI 停止刷新
for (int i = 0; i < 1000000000; i++) { ... }

此时 Event Loop 被这个循环阻塞,无法去处理 Event Queue 里的“绘图事件”(Flutter 每 1/60 秒会产生一个绘图事件)。这就是掉帧的本质。


4. 真正并行:Isolates

如果你确实有极高密度的计算任务(如图片滤镜处理、大文件加密),Future 救不了你,因为它依然在主线程跑。

这时你需要 Isolate

  • 每个 Isolate 都有自己独立的内存堆Event Loop

  • 它们之间不共享内存,只能通过“消息传递”来沟通。

  • 这才是真正的多核并行。


总结

特性 Future / async / await Isolate
执行线程 在主线程(Main Isolate)中轮询 在新的后台线程中运行
适用场景 网络请求、文件 I/O、简单的异步等待 复杂计算、解析超大 JSON、视频解码
内存共享 共享主线程内存 内存隔离,通过 Port 传值

追问1:下面代码执行逻辑

void main() {
  print("main1");
  get1(2);
  get2(2);
  get3(2).then((s) => print("get3 then"));
  get4(2);
  print("main2");
}

Future<void> get1(int s) async {
  print("get1:$s");
  await doSth(s);
  print("get1 e:$s");
}

Future<void> get3(int s) async {
  print("get3:$s");
  await doSth(s);
  print("get3 e:$s");
}

Future<void> get4(int s) async {
  print("get4:$s");
  await doSth(s);
  print("get4 e:$s");
}

Future<void> get2(int s) async {
  print("get2:$s");
  await doSth(s);
  print("get2 e:$s");
}

Future<void> doSth(int i) async {
  print("doSth:$i");
  await Future.delayed(Duration(seconds: i));
  print("doSth e:$i");
}
main1
get1:2
doSth:2
get2:2
doSth:2
get3:2
doSth:2
get4:2
doSth:2
main2
get1 e:2
doSth e:2
get2 e:2
doSth e:2
get3 e:2
get3 then
doSth e:2
get4 e:2
doSth e:2

逻辑拆解:为什么顺序是这样?

理解这个输出的关键在于:当代码运行到 await 时,函数会立即“暂停”并返回一个 Future 给调用者,剩下的代码被送入队列。

第一步:同步“扫荡” (Main Isolate 启动)
  1. main1: 最先打印。

  2. get1: 执行同步部分 -> 打印 get1:2 -> 进入 doSth -> 打印 doSth:2 -> 遇到 await Future.delayed暂停 get1

  3. get2: 执行同步部分 -> 打印 get2:2 -> 进入 doSth -> 打印 doSth:2 -> 遇到 await Future.delayed暂停 get2

  4. get3: 执行同步部分 -> 打印 get3:2 -> 进入 doSth -> 打印 doSth:2 -> 遇到 await Future.delayed暂停 get3

  5. get4: 执行同步部分 -> 打印 get4:2 -> 进入 doSth -> 打印 doSth:2 -> 遇到 await Future.delayed暂停 get4

  6. main2: 主线程同步代码全部跑完,最后打印。

第二步:2秒后的异步“收网” (Event Queue)

2秒钟后,四个 Future.delayed 计时器同时到期,它们的回调被依次放入事件队列。

  1. doSth(1) 恢复: 执行 await 之后的代码 -> 打印 doSth e:2

  2. get1 恢复: doSth 执行完了,get1 继续往下走 -> 打印 get1 e:2

  3. doSth(2) 恢复: 打印 doSth e:2

  4. get2 恢复: 打印 get2 e:2

  5. ...以此类推,直到 get3 打印 get3 e:2 后,触发了它的回调 .then,打印 get3 then


核心知识点总结

  • 异步函数的同步性async 函数在遇到第一个 await 关键字之前的代码完全是同步执行的,不会有任何延迟。

  • 链式触发await doSth(s) 之后的代码(例如 print("get1 e:2"))必须等待 doSth 彻底完成后,作为一个微任务(Microtask)被排进队列执行。

  • .then 的时机get3(...).then() 的内容会在 get3 函数体内所有代码执行完毕并返回结果后立即触发。

追问2:修改get1逻辑后的执行循序

Future<void> get1(int s) async {

  print("get1:$s");

  await doSth(s);

  doSth(s);

  print("get1 e:$s");

}
main1
get1:2
doSth:2
get2:2
doSth:2
get3:2
doSth:2
get4:2
doSth:2
main2
doSth e:2
get1: doSth:2    <-- 第二次调用 doSth,它是同步开始的
get1 e:2         <-- 因为没加 await,它直接打印了 e
doSth e:2
get2 e:2
doSth e:2
get3 e:2
get3 then
doSth e:2
get4 e:2
doSth e:2        <-- 这个是 get1 里面第二次调用的 doSth 的结尾

深度解析:发生了什么?

这里的核心在于 get1 函数体内的这两行:

await doSth(s); // 1. 挂起,等待 2 秒
doSth(s);       // 2. 启动,但不等待
print("get1 e:$s"); // 3. 紧接着第 2 步执行
1. 2秒钟后的“连锁反应”

当第一个 await doSth(s) 到期时:

  • Event Loop 执行完 doSth 剩余的代码(打印 doSth e:2)。

  • 回到 get1 继续执行。

2. “火上浇油”的同步调用
  • get1 执行第二行 doSth(s)

  • 记住原则async 函数在遇到第一个 await 之前是同步的。

  • 所以它立刻跳进 doSth 打印了 doSth:2

  • 然后 doSth 内部遇到了 await Future.delayed,于是这个新的 doSth 任务被丢进 Event Queue。

  • 关键点:由于 get1 这一行没写 await,它不等了,直接跑下一行打印 get1 e:2

3. 最后的长尾
  • 其他的 get2, get3, get4 依次恢复执行。

  • 大约在整个程序运行的 第 4 秒(2s + 2s),最后那个没被等待的 doSth 才会打印出它的 doSth e:2


总结:await 与不 await 的区别

  • await doSth(): “请去煮咖啡,我在这等着,咖啡好了我再干活。”

  • doSth(): “去煮咖啡吧,我接着看我的报纸了(print),咖啡什么时候好随它便。”

这就是为什么 get1 e:2 会出现在其他 get 函数执行完之前的原因——它“插队”完成了。

追问3:有一个异步方法,调用它的地方是某一个方法的末尾,此时await和不await有什么区别

这是一个非常经典的问题。在方法的末尾,代码看似已经“结束”了,但 await 和不 await 会在 异常捕获、生命周期、以及函数返回时机 上产生巨大的区别。

我们可以把不带 await 的调用称为 "Fire and Forget"(发射后不管)


1. 核心区别对比

特性 使用 await 不使用 await
执行顺序 当前方法会“暂停”,等待异步任务彻底完成后才结束。 当前方法立即结束,异步任务在后台继续跑。
异常捕获 可以通过 try-catch 捕获到异步任务内部的错误。 捕获不到。错误会抛向全局,可能导致应用崩溃。
生命周期 保证了任务在方法返回前完成。 任务可能在方法所属的对象销毁后还在运行(风险点)。
返回类型 调用者会等待这个方法的 Future 完成。 调用者会立即收到结果,不管那个异步任务。

2. 场景演示:异常捕获(最危险的区别)

如果在方法末尾调用一个可能报错的任务:

情况 A:使用 await

Future<void> saveAndLog() async {
  try {
    await uploadData();
    await logAction(); // 在末尾 await
  } catch (e) {
    print("捕获到了错误: $e"); // 如果 logAction 出错,这里能抓到
  }
}

情况 B:不使用 await

Future<void> saveAndLog() async {
  try {
    await uploadData();
    logAction(); // 发射后不管
  } catch (e) {
    print("捕获不到 logAction 的错误"); 
  }
}
// 如果 logAction 报错,它会变成一个 "Uncaught Error",
// try-catch 无法拦截,因为 saveAndLog 此时已经跑完了。

3. Flutter 特有风险:内存泄露与 Context

在 Flutter UI 开发中,如果你在方法末尾不 await 且该任务涉及 contextsetState,会非常危险。

void _onButtonPressed(BuildContext context) async {
  await doMainTask();
  
  // 如果不 await 且任务很慢
  updateDatabase(); 
  
  // 假设用户在 updateDatabase 完成前关闭了页面
  // updateDatabase 内部如果有引用 context,可能会报错或造成内存泄露
}

4. 什么时候可以不 await

虽然大部分时候建议显式等待,但以下情况可以选择不 await

  • 完全解耦的任务:比如“打点监控”、“统计上传”,主流程不关心它们是否成功。

  • 性能优化:如果你确定后续操作不需要该结果,且任务是安全的。

  • 避免死锁:某些极端的链式调用场景。

最佳实践提示: > 如果你决定不 await,建议至少链上一个 .catchError(),防止异步错误导致 App 闪退:

logAction().catchError((e) => print(e));


总结:你的意图是什么?

  • 如果你希望 确保任务一定完成 或者 需要处理潜在的报错,请务必 await

  • 如果你希望 UI 立即响应,且该异步任务是一个“后台副作用”,可以不 await,但要做好错误处理。

Logo

开源鸿蒙跨平台开发社区汇聚开发者与厂商,共建“一次开发,多端部署”的开源生态,致力于降低跨端开发门槛,推动万物智联创新。

更多推荐