《Isolate 与并发:从 compute 到 Isolate 组》

Flutter 并发全解析:单线程事件循环模型、compute 与 top-level 函数、Isolate 生命周期、消息传递与 Isolate 组共享内存、常见并发场景与陷阱。

开篇:当主线程被一帧卡住时

当你的 Flutter 应用需要解码一张 8000 万像素的相机照片、需要解析一个 50MB 的 JSON 数据包、或需要在列表滑动时做复杂排序时,主 Isolate 上哪怕 100ms 的阻塞都会让动画直接掉帧,用户会立刻感知到"卡了一下"。

  • 大图解码与缩略图生成导致列表滚动卡顿
  • 超大 JSON/XML 文档解析阻塞按钮点击响应
  • 加密、压缩、数据库迁移等重活占用主线程
  • 批量文件 IO(读取、写入、哈希)串行执行缓慢

这些问题都指向同一个事实:Dart 默认是单线程的,而"单线程"不等于"不能并发"。Isolate 就是 Dart 的并发原语——每个 Isolate 拥有独立的内存堆与事件循环,可以在多核 CPU 上并行执行。本文将带你理解事件循环模型,掌握从 compute 到 Isolate.spawn、再到 Isolate 组共享内存的完整进阶路径。


一、单线程事件循环模型

1.1 Dart 的事件循环

Dart 的单线程实际上是"事件循环 + 微任务队列"模型:

事件循环(Event Loop)
 ├── 微任务队列(Microtask Queue)→ 每次事件后优先清空
 └── 事件队列(Event Queue)→ 定时器、IO、手势等事件
void main() {
  // 执行顺序:同步代码 → 微任务 → 事件
  Future(() => print('event-1'));        // 事件队列
  scheduleMicrotask(() => print('micro')); // 微任务
  Future(() => print('event-2'));        // 事件队列
  print('sync');
}
// 输出:sync → micro → event-1 → event-2

1.2 阻塞主 Isolate 的代价

主 Isolate 既要跑 Dart 业务,又要驱动 UI 渲染。任何一个同步的重计算都会阻塞两者:

// 危险:在主 Isolate 同步做百万次循环
final list = List.generate(1000000, (i) => i);
list.sort((a, b) => b - a); // 主线程卡顿,掉帧
阻塞时长用户感知
< 16ms无感知(一帧内完成)
16~100ms轻微掉帧,滚动不跟手
> 100ms明显卡顿,触控无响应

1.3 何时需要并发

  • 单次计算超过一帧预算(16ms)
  • 计算可以拆分为独立任务并行执行
  • 需要利用多核 CPU 提升吞吐

一句话总结:判断要不要并发,先问"这段代码是否会让主线程单次阻塞超过 16ms"。


二、compute 与 top-level 函数

2.1 compute 快速上手

compute 是 foundation 提供的便捷 API,一个调用就能把函数丢到后台 Isolate:

import 'package:flutter/foundation.dart';

// top-level 函数(必须是全局函数或静态方法)
int fibonacci(int n) {
  if (n < 2) return n;
  return fibonacci(n - 1) + fibonacci(n - 2);
}

// UI 中调用
Future<void> onClick() async {
  final result = await compute(fibonacci, 40);
  setState(() => _result = result);
}

2.2 闭包与参数限制

compute 只能传一个参数,且要求是可发送(sendable)的类型:

  • 基本类型、不可变对象、List/Map 中的可发送值可以传
  • 闭包捕获的复杂对象、带 intptr 的对象不能传
  • 多个参数时用记录(record)或自定义可发送数据类打包
// 多参数:打包成 record
Future<Map<String, int>> processBatch(List<String> urls) {
  return compute((batch) {
    final (paths, limit) = batch;
    // 并发处理
    return {'done': paths.length, 'ok': limit};
  }, (urls, 10)); // record 作为单个参数
}

2.3 compute 的局限

  • 每次调用新建一个 Isolate,有固定创建开销,高频调用不划算
  • 只适合"一次性"任务,无法做流式、增量或长驻后台
  • 返回值同样受可发送约束限制

一句话总结:compute 是"一次性后台任务"的甜点 API,够快够省心,但别让它承担长驻 worker 的职责。


三、Isolate 生命周期

3.1 Isolate.spawn 手动创建

import 'dart:isolate';

Future<Isolate> spawnWorker(SendPort toMain) async {
  return Isolate.spawn(workerEntry, toMain);
}

// 后台 Isolate 的入口
void workerEntry(SendPort toMain) {
  // 在后台 Isolate 中准备回复用的端口
  final receivePort = ReceivePort();
  toMain.send(receivePort.sendPort);

  receivePort.listen((message) {
    final replyPort = message['reply'] as SendPort;
    replyPort.send(_heavyWork(message['data']));
  });
}

3.2 生命周期管理与退出

// 后台任务完成后主动退出
void workerEntry(SendPort toMain) {
  toMain.send('done');
  Isolate.exit(); // 干净退出,触发当前 Isolate 的退出钩子
}

// 主侧终止(慎用,无法优雅清理)
isolate.kill(priority: Isolate.immediate);

3.3 错误传播

Isolate 之间错误不会自动传播,需要监听:

final receivePort = ReceivePort();
isolate.addErrorListener(receivePort.sendPort);
// 后台 Isolate 抛出的未捕获异常会送达此端口
receivePort.listen((message) {
  if (message is IsolateError) {
    debugPrint('后台 Isolate 出错:${message.error}');
  }
});

一句话总结:compute 隐藏了这一切,而 Isolate.spawn 让你拥有创建、通信、退出与错误传播的完整控制权。


四、消息传递与 ReceivePort

4.1 SendPort / ReceivePort 配对

任何两个 Isolate 之间通信,都靠一对 SendPort / ReceivePort:

主 Isolate ◄─────────────── 后台 Isolate
   ▲                            │
   │   SendPort.send(msg)      ReceivePort.listen
   └────────────────────────────┘

4.2 消息类型与深拷贝

  • 消息传递是深拷贝(除非走共享内存),传递大对象有拷贝开销
  • 可发送类型:Null、bool、int、double、String、可发送类型的 List/Map、SendPort、TransferableTypedData
// 传递大量二进制时优先用 TransferableTypedData
final data = Uint8List.fromList(largeBytes);
final transferable = TransferableTypedData.fromList([data]);
sendPort.send(transferable);

4.3 双向通信与 Isolate.run

现代 Dart 提供了 Isolate.run,把"创建 Isolate + 通信 + 收结果 + 销毁"压缩成一行:

Future<int> computeAsync() {
  return Isolate.run(() {
    // 自动在后台 Isolate 执行,自动传参、自动收结果
    return heavyCalculation();
  });
}

一句话总结:需要持续双向通信时用 SendPort/ReceivePort,只求一次结果时用 Isolate.run 更省心。


五、Isolate 组共享内存

5.1 Isolate Group 概念

通过 Isolate.spawn 创建的 Isolate 属于同一个 Isolate Group,共享部分内部结构(如常量池、部分运行时数据),但 Dart 堆互不共享。

5.2 TransferableTypedData:零拷贝共享

共享内存的典型做法是把字节所有权"转移"给目标 Isolate,避免拷贝:

import 'dart:isolate';

Future<void> sendBigData(Isolate worker) async {
  final rawData = Uint8List.fromList(List.generate(1 << 20, (i) => i % 256));
  // 转移所有权而非拷贝
  final transferable = TransferableTypedData.fromList([rawData]);
  worker.send(transferable);
}

// 接收端取回字节,且不再拷贝
void receiveData(TransferableTypedData data) {
  final bytes = data.materialize().asUint8List();
}

5.3 共享内存的限制

方案是否共享 Dart 堆是否零拷贝
普通 send 消息否(深拷贝)否
TransferableTypedData转移所有权是
原生内存(FFI)是(绕过 Dart 堆)是

一句话总结:需要高频大块字节流转时,用 TransferableTypedData 或 FFI 共享内存,别让深拷贝吃掉并发收益。


六、并发场景与注意事项

6.1 典型场景:图片处理 / 解析 / IO

// 图片缩略图生成
Future<Uint8List> makeThumbnail(Uint8List origin, int size) {
  return compute((params) {
    final (bytes, targetSize) = params;
    final codec = await ui.instantiateImageCodec(bytes);
    final frame = await codec.getNextFrame();
    final scaled = frame.image.scaled(targetSize);
    return scaled.toByteData(format: ui.ImageByteFormat.png)!.buffer.asUint8List();
  }, (origin, size));
}

// 大 JSON 解析
Future<Map<String, dynamic>> parseBigJson(String json) {
  return compute((s) => jsonDecode(s) as Map<String, dynamic>, json);
}

// 批量文件哈希
Future<List<String>> hashFiles(List<String> paths) {
  return Isolate.run(() {
    return paths.map((p) => sha256(p)).toList();
  });
}

6.2 并发池与 worker 管理

高频任务不要每回都新建 Isolate,维护一个 worker 池:

class WorkerPool {
  final List<Isolate> _workers = [];
  int _index = 0;

  Future<void> ensureWorkers(int count) async {
    for (var i = 0; i < count; i++) {
      _workers.add(await Isolate.spawn(workerLoop, null));
    }
  }

  Isolate _next() => _workers[_index++ % _workers.length];
}

6.3 陷阱与最佳实践

  • 陷阱一:在 Isolate 里访问 UI——绝对禁止,Flutter 组件只活在主 Isolate
  • 陷阱二:误以为 Isolate 共享变量——它们的内存堆是隔离的
  • 陷阱三:高频 compute——创建开销大于收益时改用常驻 worker
  • 陷阱四:debug 模式测性能——debug 下 JIT 远慢于 release,务必用 release 档评估
// 反例:把 UI 相关对象塞进 compute
await compute((context) {
  // Navigator 不可以在后台 Isolate 使用
  // context 相关代码会在运行时抛错或静默失效
}, context);

一句话总结:Isolate 解决的是"计算并行",UI 更新永远回主线程;规模大的并发任务用 worker 池,单次任务用 compute/Isolate.run。


FAQ

常见问题:compute 和 Isolate.spawn 有什么区别?

答:compute 是 Isolate.spawn 的封装,自动处理创建、通信、收结果与销毁,适合一次性任务;Isolate.spawn 允许常驻后台、双向持续通信、自定义错误监听,适合需要长期复用与协作的场景。

常见问题:多个 Isolate 之间能直接共享一个对象吗?

答:不能。Dart Isolate 的内存堆彼此隔离,普通对象传递一律深拷贝。需要零拷贝共享时,用 TransferableTypedData 转移字节所有权,或通过 FFI 共享原生内存。

常见问题:怎么控制并发的数量?

答:维护一个 worker 池并限制数量,或用信号量(如 package:synchronized)限流。同时要了解设备 CPU 核数(Platform.numberOfProcessors),不要无脑开满线程。

常见问题:为什么不能在后台 Isolate 里更新 UI?

答:Flutter 的渲染与组件树绑定在主 Isolate。后台 Isolate 只有计算能力,必须把结果通过消息端口送回主 Isolate,再由 setState 等方式更新界面。

常见问题:后台任务做不完,进程被杀怎么办?

答:Isolate 不会延长应用生命周期。若任务必须在应用被杀前完成(如写入数据库),应在主 Isolate 用 WidgetsBindingObserver 监听生命周期,在前台转后台时触发同步式收尾。


相关阅读

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「Flutter」更多文章

  1. 《响应式与自适应布局:从手机到桌面》
  2. 《深度链接与路由进阶:从内部导航到跨端唤起》
  3. 《本地数据库持久化:sqflite、drift 与对象存储》