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

Isolate:真并行,以及你多半不需要它

直觉过境 「重活开个后台线程就行。」 得扔掉

Dart 确实有真并行——但它不叫「线程」,叫 isolate,而且它不共享内存,两个 isolate 之间传数据要靠复制。它更像多进程。这一章讲清楚:什么时候你才真的需要它(比你想的少)、怎么用一行 Isolate.run 搞定,以及它为什么让 Dart 的并发从「危险」变成了「无脑安全」。

不共享内存消息传递Isolate.run何时才需要

先分清两种「重活」

第 18 章埋了这个区分,这里说透。你的 App 会遇到两种拖慢它的活,它们的解法完全不同

等待型(I/O-bound)计算型(CPU-bound)
特征大部分时间在(网络、磁盘、数据库)大部分时间在(CPU 满载)
例子拉课程列表、读缓存文件解析超大 JSON、图片处理、加密、复杂计算
解法await——等待时线程空闲,界面照常刷Isolate——真的要另一个核去算
占比你 95% 的异步少数,但一旦有就必须处理
◆ 为什么 await 救不了计算型

await 的本事是「等待期间把线程让出去」(第 18 章)。但纯计算没有「等待」可让——它每一毫秒都在真真切切地占用 CPU。你把一个跑 800ms 的计算写成 async、加上 await,它照样会把这根线程占满 800ms,界面照样冻 800ms。让出线程的前提是有空闲,而满载的计算没有空闲。这时唯一的出路是:把它搬到另一个核上去算——那就是 Isolate。

Isolate:隔离的执行单元,不共享内存

「isolate」这个名字就是「隔离」。每个 isolate 有自己独立的内存堆和自己的事件循环。你的整个 App 默认跑在一个叫 main isolate 的里面(第 18 章那根线程就是它的)。

关键性质,也是它和线程最大的不同:

◆ Isolate 不共享内存,只传消息

两个 isolate 看不到对方的变量、对象、状态。它们之间唯一的沟通方式是传消息——而消息在传递时会被复制一份(除了少数可转移的类型)。

所以它更像多进程,不像多线程:没有共享状态,就没有数据竞争、没有锁、没有 synchronized、没有 volatileDart 的并发用「隔离 + 复制」换掉了「共享 + 加锁」——你为多线程安全操的那些心,在这里根本不存在。

用起来:Isolate.run 一行搞定

好消息是,现代 Dart 把常见用法简化到了一行。要在别的核上跑一个计算、拿回结果,用 Isolate.run

// ✗ 卡界面 800ms
final result = parseHugeJson(bigString);      // 同步跑在 main isolate

// ✓ 甩到另一个 isolate,界面全程流畅
final result = await Isolate.run(() => parseHugeJson(bigString));

就这么简单——Isolate.run 开一个临时 isolate、跑你的函数、把结果复制回来、关掉它。你 await 它就行。这覆盖了 90% 的「计算型重活」需求。

Flutter 还提供一个更老的便捷函数 compute,语义类似(顶层函数 + 一个参数):

final result = await compute(parseHugeJson, bigString);

新代码优先 Isolate.run(更灵活,支持闭包)。要长期运行、双向通信的场景(比如一个后台一直在处理任务的 isolate),才需要手动 Isolate.spawn + SendPort/ReceivePort 那套——但那属于少数情况,用到再查。

⚠ Isolate 的两个代价,决定了它不能滥用

1. 传数据要复制,大对象很贵。你传进去的参数、传出来的结果都会被复制一遍。如果数据本身就有几十 MB,复制的开销可能抵消甚至超过并行省下的时间。所以 Isolate 适合「输入输出小、但中间计算重」的活(传一个字符串进去、算很久、返回一个数字),不适合「搬运大量数据」。

2. 启动有开销。开一个 isolate 不是免费的。为一个 1ms 的小计算开 isolate,得不偿失。只有当计算真的会卡出可见的掉帧(几十毫秒以上)时,才值得。

那到底什么时候才真需要它

把判断收敛成一句话:当你在 main isolate 上做一件纯计算的事、它长到会掉帧、而且这件事的输入输出数据量不大——这时才用 Isolate。具体场景:

  • 解析一个很大的 JSON/CSV(比如一次性导入几千条成绩)。
  • 图片处理:压缩、缩放、滤镜、生成缩略图。
  • 加解密、哈希一个大文件。
  • 复杂的本地计算:路径规划、数据聚合统计、离线搜索建索引。

你的学校 App 大概率能撞上的就一两个——比如「导入班级名单时解析一个大表格」。除此之外,日常的网络请求、数据库读写、界面交互,全都用 await 就够了,一个 isolate 都不用开。这就是标题说「你多半不需要它」的意思。

✎ 怎么判断某个操作该不该 isolate

别猜,测。用 DevTools 的 Performance 页录一段,看那个卡顿的操作在时间线上占了多少 ms。如果某个同步操作超过 16ms(一帧)、而它是纯计算,就考虑 Isolate.run如果它其实是在等 I/O(时间线上显示为等待而非 CPU 占用),那你要的是 await,不是 isolate。

一个你天天在享受、却不知道的 isolate

顺带说个冷知识:Flutter 引擎自己会在别的线程上做一些活——比如图片的解码、部分光栅化工作。所以你 Image.network 加载一张大图时,解码不会卡你的 main isolate,这是引擎替你并行了。你能控制的 isolate 是 Dart 层的计算,而引擎层的并行是免费赠送的。这也是为什么 Flutter 的图片加载体验通常很顺。

一个完整例子:导入班级名单不卡界面

把这一章落到你会真的写的代码上。「导入班级名单」——用户选一个几千行的 CSV/Excel,你要解析成对象、可能还要做些校验和统计。这正是「输入是个字符串、中间算很久、输出是结构化数据」的典型,教科书级的 isolate 场景:

// 这个函数会在另一个 isolate 里跑,所以它必须是「自包含」的:
// 不碰任何 Widget、不碰 main isolate 的全局变量,只吃参数、吐结果
List<Student> _parseRoster(String csv) {
  return csv
      .split('\n')
      .skip(1)                                   // 跳过表头
      .where((line) => line.trim().isNotEmpty)
      .map((line) {
        final cols = line.split(',');
        return Student(id: cols[0], name: cols[1], className: cols[2]);
      })
      .toList();
}

// UI 侧:一行甩过去,界面全程能转圈、能取消
Future<void> onImport(String csv) async {
  setState(() => _importing = true);
  final students = await Isolate.run(() => _parseRoster(csv));  // 另一个核在算
  if (!mounted) return;                          // 第 19 章:回来先检查页面还在不
  setState(() { _students = students; _importing = false; });
}

关键点:那个跑在别的 isolate 里的函数必须自包含——它拿不到 main isolate 的任何对象(不共享内存),所以别在里面 ref.read、别引用 context、别读全局单例。你要的东西全部通过参数传进去(会被复制),结果通过返回值传回来(也会被复制)。这个约束一开始有点别扭,但它正是「无并发 bug」的来源。

⚠ 传给 isolate 的东西必须能被复制

不是所有对象都能跨 isolate 传。能传的:基本类型、String、List/Map、以及大部分纯数据对象(比如你的 freezed 模型)。不能传的:带 BuildContext 的东西、打开的 socket/文件句柄、含闭包状态的对象、平台通道相关的对象。传了不能复制的会当场抛异常。经验:只在 isolate 边界传「纯数据」进去、传「纯数据」出来,把它当成一个纯函数用,就不会踩坑。

⇄ Compose 对照 · 从「共享内存 + 锁」到「隔离 + 消息」
Kotlin / JVMDart
多个线程共享同一块内存堆每个 isolate 独立内存堆
线程间直接读写同一对象只能传消息,消息被复制
synchronized/Mutex/@Volatile 防竞争不需要——没有共享就没有竞争
Dispatchers.Default 跑 CPU 重活Isolate.run { }
线程池廉价,随便切isolate 较重,慎开、只为大计算开

这是一次真正的思维切换,不是换个 API 名。你在 JVM 上积累的「小心并发 bug」的经验——竞态、可见性、死锁——在 Dart 里大半用不上了,因为模型根本不共享内存。代价是并行的「粒度」变粗了(不能像开线程那样随手开、不能共享大对象),但对一个 App 来说,这个取舍几乎总是划算的:你失去的是很少用到的细粒度并行,换来的是「几乎不会写出并发 bug」。

这一章的一句话

Isolate 是 Dart 的真并行,但它不共享内存、传数据靠复制,更像多进程——所以用它换来了「几乎不会有并发 bug」;而你多半用不上它:等待型的活 await 就够,只有「会掉帧的纯计算、且数据量不大」才值得 Isolate.run 甩到另一个核上。

卷 V 到此结束——你已经能推理 Flutter 的时间维度了:为什么不卡、异步怎么写、重活往哪放。最后一卷,我们把前面所有东西拼成一个真的能交付的 App。下一章从导航开始:那个学校 App 有登录页、课表页、详情页,用户点来点去、还要能从通知直接跳进某节课——这就是路由要解决的问题。