Flutte 指北 -> Isolate

5,641 阅读9分钟

写在黎明破晓前

对于单线程的程序,同一个时间内只会有一段代码在执行,对内存中状态的访问和改变都是独占发生的。

but...

现代的设备基本都是多核的 CPU,为了提高效率,一般都会使用共享内容的线程来并发运行代码。

of course...

内容的共享可能会产生 竞态条件,从而造成错误,也会增加代码的复杂度。

of course...

我们可以使用锁来解决竞态条件的问题。

but...

锁的使用意味如果某资源在使用,那么后来的调用者(或者说,线程),除了等待之外无法做任务其他有意义的事情。这即使对于性能越来越高的设备,也是不能被接受的。另外,锁的另一个问题在于它需要被精心设计,单个锁还好,但是随着锁的增加,可能会出现死锁(deadlock)等问题。

and...

锁的种类繁多,乐观锁悲观锁 你怕了吗?

lock.jpeg

so...

Isolate 应运而生。

Flutter 中的 Isolate

Isolate 之前,先来简单介绍一下 Flutter 中的异步是怎么一回事。

实现异步一般有两种方式:一种是 多线程,另一种是 基于事件的异步模型。多线程我们不提,基于事件的异步模型简单来说就是某个单线程中存在一个事件循环和一个事件队列,事件循环不断的从事件队列中取出事件来执行,当循环遇到一个耗时事件,它不会停下来等待,而是会跳过该事件继续往下执行,当不耗时的事件处理完了,再回过头来查看耗时事件的结果。所以,耗时事件不会阻塞循环,而在耗时事件之后的事件也就有机会被执行。

很容易发现,这种基于事件的异步模型比较适合 I/O 密集型的耗时操作,因为 I/O 耗时操作,往往把时间浪费在等待对方传送数据或者返回结果,因此这种异步模型往往用于网络服务器并发。如果是计算密集型的操作,则应当尽可能利用处理器的多核,实现并行计算。

这和 Isolate 有什么关系呢?

Flutter 的 main 函数是被一个隔离域包裹起来的,可以称为 main Isolate,其实每个 Isolate 中都会有一份独立的内存和一个事件循环以及事件循环队列,也会有 一个执行事件循环的线程

看一下 Flutter 中的消息队列机制,消息队列采用先进先出:

dart-event-loop.png

将消息转换成具体的类型就是:

dart-event-loop-and-main.png

如果我们不新开一个 Isolate,那么默认所有的代码都会运行在 main Isolate 之中,而它只对应了一条线程,这也就是为什么我们说 Flutter 是单线程的一个原因。

Isolate 翻译过来是隔离域,所谓隔离,隔离的是内存,内存都隔离了,对象直接也不能直接访问,所以也不会涉及到共享资源的问题,所以也就不需要考虑多线程的那些令人头疼的问题了。

isolate隔离示意图.jpg

(图片出自 Flutter异步编程-Isolate)

iOS 中也有类似的概念:Actor

当然,Isolate 之间是可以相互通信的的,是通过消息传递的方式。

but...

并非所有的对象都满足传递条件,在无法满足条件时,消息发送会失败。

举个🌰...

如果你想发送一个 List<Object>,你需要确保这个列表中的所有元素都是可被传递的。假设这个列表中有一个 Socket,由于它无法被传递,所以你无法发送整个列表。

and...

一个 Isolate 在阻塞时不会对其他 Isolate 造成影响。

so...

其实可以看出 Isolate 与线程和进程的概念是近似的,不同的是:每个 Isolate 都拥有 独立的内存,以及 运行事件循环的独立线程

一个直观的比较

下面给出的 Demo 是给出了直接使用 async/await 和使用了 Isolate 之后的一个区别:

1.gif

上面按钮是使用了 Isolate 进行耗时操作,可以看到对 UI 几乎没有影响,而下面的按钮执行的是同样的操作,但是没有使用 Isolate,而是直接使用了 async/await,UI 直接就卡住了。

如何使用

有三种方式:

  • Dart - Isolatestatic Future<Isolate> spawnUri()
  • Dart - Isolatestatic Future<Isolate> spawn()
  • Flutter - 直接使用 compute()

spawnUri

spawnUri 有三个必须的参数:

  • 第一个是 Uri,指定一个新 Isolate 代码文件的路径
  • 第二个是参数列表,类型是 List<String>
  • 第三个是动态消息,类型是 dynamic

用于运行新 Isolate 的代码文件中,必须包含一个 main 函数,作为新 Isolate 的入口方法。该 main 函数的 args 参数列表,就是 spawnUri 的第二个参数,如果不需要,传空 List 即可。第三个参数,一般传入调用者的 SendPort,用于发送消息。

来看一个 Demo:

// 主 Isolate
import 'dart:isolate';

void main(List<String> args) {
  startBusyTask();
}

void startBusyTask() async {
  await spawnUrlTest();
}

Future spawnUrlTest() async {
  ReceivePort receivePort = ReceivePort();
  var isolate = await Isolate.spawnUri(Uri(path: 'isolate_spawn_uri_task.dart'), ['isolate', 'spawnUri', 'test'], receivePort.sendPort);

  receivePort.listen((message) {
    print('message from spawnUri test is $message');
  });
}

然后新开的 Isolate

import 'dart:isolate';

void main(List<String> args, SendPort sendPortFromCaller) async {
  var result = await calculateCount();
  sendPortFromCaller.send(result);
  // 不能使用,因为 exit 只能在相同 group 的 isolate 中使用
  // Isolate.exit(sendPortFromCaller, result);
}

Future<int> calculateCount(int targetCount) async {
  var totalCount = 0;
  for (var i = 0; i < 2000000000; i++) {
    totalCount += i + i + 1;
  }
  return totalCount;
}

运行之后输出:

message from spawnUri test is 4000000000000000000

spawn

spawn 有两个必须的参数

  • 需要运行的函数(耗时任务)
  • 动态消息,通常用于传递 main IsolateSendPort 对象

在 Dart 2.15 之后,提出了一个 Isolate 组的概念,在 Isolate 组中的 isolate 共享各种内部数据结构,共享堆内存。但是组员 isolate 之间仍然不支持共享访问对象,但是由于共享堆内存,所以让对象的直接传递成为可能,之前都是使用 send 方法传递对象,send 方法会先深度复制一份对象再进行传递,当对象很复杂时,深复制会消耗时间,有可能会对程序造成卡顿。Dart 2.15 之后,组员 isolate 之间可以使用 exit 来进行对象的传递,exit 省略了深复制这个过程,直接传递对象,这样就提高了效率。并且,因为不需要初始化程序结构,组中的单个 isolate 的创建更加的轻便,据官方的说法,在现有 Isolate 组 中启动额外的 isolate 比之前快 100 多倍,并且产生的 isolate 所消耗的内存减少了 10 至 100 倍。

spawn 方法,使用的就是 Isolate 组, 也就意味着生成的 isolate 可以使用 exit 方法进行参数的传递,下面看一个 demo,使用 spawn 来改写一下上面的实现:

void main() {
  var totalCount = await createTask();
  print($totalCount);
}

Future createIsolate() async {
  ReceivePort receivePort = ReceivePort();
  await Isolate.spawn(doBusyTaskInBackground, receivePort.sendPort);
  return receivePort.first;
}

Future doBusyTaskInBackground(SendPort sendPort) async {
  final calculateResult = await calculateCount();
  return Isolate.exit(sendPort, calculateResult);
}

Future<int> calculateCount() async {
  var totalCount = 0;
  for (var i = 0; i < 2000000000; i++) {
    totalCount += i + i + 1;
  }
  return totalCount;
}

输出:

4000000000000000000

注意:

不管是 spawn 还是 spawnUri,需要注意的是,不能将 spawn 或者 spawnUri 的代码放在有 dart:ui 的代码文件中,官方说法是入口函数是顶级函数或者静态函数,所以它们仅能放在你处理业务逻辑的代码之中,否则你会得到如下的错误:

ArgumentError (Invalid argument(s): Illegal argument in isolate message: (object extends NativeWrapper - Library:'dart:ui' Class: Path))

compute

可以看到 Dart 中创建一个 Isolate 显得有些繁琐,Flutter 官方进一步封装提供了更为简便的 API, 它就是 compute,位于 package:flutter/foundation.dart 中。看一下使用 compute 如何改写上面的程序:

void main() {
  var totalCount = await compute(calculateCount, 1);
  print('$totalCount');
}

Future<int> calculateCount(int s) async {
  var totalCount = 0;
  for (var i = 0; i < 2000000000; i++) {
    totalCount += i + i + 1;
  }
  return totalCount;
}

它有两个必须的参数:

  • 要执行的方法
  • 动态的消息类型,可以是被运行函数的参数

需要注意的是,compute 传入的方法必须带一个参数,这里我就随便传了一个参数。

在 Dart 2.15 之后,compute 也是使用了 Isolate 组,使用 Isolate.exit 来进行消息的传递,所以如果你之前也使用了 compute,不需要做任何改动就能得到这些性能的提升。

数据的双向流转

isolate_switch_message.png

上面的 Demo 的数据都是从新 Isolate 流向 main Isolate , 那么 main Isolate 如果向其余 Isolate 发送数据呢?当然也是通过 SendPortSendPortReceivePort 就是 Isolate 之间的消息传递通道。

但是一对 SendPortReceivePort 管道的数据是单向流转的,如果需要互相通信,那么就需要两根管道,利用 ReceivePort.listen 方法,就可以监听到 SendPort 所发送过来的数据了。

处理连续的流数据

其实流数据的处理也简单,只要在接收到一个流数据处理的结果之后,使用 Isolate.send 进行数据的发送即可。

可以看看这篇 Dart 2.15 更新后 isolate 应该这么用,里面有流数据处理的一个小 Demo。

使用场景

Isolate 也不能滥用,应尽可能多的使用 Dart 中的事件循环机制去处理异步任务,这样才能更好的发挥 Dart 语言的优势。对于什么时候使用 FutureIsolate,一个最简单的判断方法就是根据任务的耗时来选择:

  • 耗时几毫秒或者十几毫秒左右的,应使用 Future
  • 其余耗时更多的应使用 Isolate 来实现

一些参考场景:

  • JSON 解码
  • 加密
  • 图像处理:比如裁剪
  • 网络请求:加载资源、图片

性能测试

测试了一下 Isolate 的性能,执行一个很简单的任务:

static Future aSingleTask(int i) async {}

然后使用 Xcode 的 instrument 对性能进行测试,分别创建对应 Isolate 个数的 Isolate 执行任务(使用 compute ),记录性能消耗如下表:

Isolate(个数)CPU(%)内存(M)CPU恢复时间(s)线程数(DarkWorker)
01012801
101814028
50130及以上14358
100130及以上1481210
200130及以上173228
500130及以上164559
1000130及以上1621159

可以看到当 Isolate 的个数增多,CPU 是主要的瓶颈,而且个数越多,CPU 恢复正常的时间就越长,可以看到随着 Isolate 个数的增多,线程数也在增多,不过当然也会通过线程池进行复用,所以最大线程数不会太多。

另外,通过 TimeProfiler 可以可以看到 CPU 主要是在执行 pthread_start,开启新线程:

1000个Isolate的TimeProfiler.png

通过观察,得出了几个点:

  • 注意不能同时创建过多的 Isolate,可以不使用 compute 转而自己维护一个 Isolate,但是这样的话就需要使用 send 来进行消息传递,因为使用 exit 会关闭 Isolate

  • 在处理简单任务时,调用同等次数的 async 方法的性能消耗远比 Isolate 低,几乎没有变化。

由于本身 Isolate 也需要耗费性能,所以要谨慎使用 Isolate,在遇到计算密集的操作时再去使用 Isolate ,同时要注意不要同时创建过多的 Isolate,如果必须这样,考虑自己维护一个 Isolate

写在日落黄昏后

总结一下,这篇文章主要是讲了以下几点:

  • Isolate 的概念
  • Dart 2.15 之后 Isolate
  • 比较了使用 Isolate 和不使用 Isolate 的性能差异
  • Isolate 在 Flutter 中的使用
  • 简单列举 Isolate 的使用场景
  • 测试了一下 Isolate 对性能的消耗

另外,我测试了一下网络库 Dio,一下子发了 1000 个请求,发现 UI 并没有卡顿,所以项目中如果是使用 Dio 来进行网络请求的,直接使用即可不用担心性能的问题,关于 Dio 是如何实现的,我还没有看源码,这个就留到以后吧。

参考文章:

Dart 2.15 的更新

Dart 语言异步编程之Isolate

干货 | Dart 并发机制详解

Dart 2.15 更新后 isolate 应该这么用

这是一个视频:

Isolates and multithreading in Flutter

OK,那么本文就结束了,如果你觉得本文对你有帮助的话,留个赞再走吧~