Flutte 指北 -> Isolate
写在黎明破晓前
对于单线程的程序,同一个时间内只会有一段代码在执行,对内存中状态的访问和改变都是独占发生的。
but...
现代的设备基本都是多核的 CPU,为了提高效率,一般都会使用共享内容的线程来并发运行代码。
of course...
内容的共享可能会产生 竞态条件,从而造成错误,也会增加代码的复杂度。
of course...
我们可以使用锁来解决竞态条件的问题。
but...
锁的使用意味如果某资源在使用,那么后来的调用者(或者说,线程),除了等待之外无法做任务其他有意义的事情。这即使对于性能越来越高的设备,也是不能被接受的。另外,锁的另一个问题在于它需要被精心设计,单个锁还好,但是随着锁的增加,可能会出现死锁(deadlock)等问题。
and...
锁的种类繁多,乐观锁、悲观锁 你怕了吗?
so...
Isolate
应运而生。
Flutter 中的 Isolate
谈 Isolate
之前,先来简单介绍一下 Flutter 中的异步是怎么一回事。
实现异步一般有两种方式:一种是 多线程,另一种是 基于事件的异步模型。多线程我们不提,基于事件的异步模型简单来说就是某个单线程中存在一个事件循环和一个事件队列,事件循环不断的从事件队列中取出事件来执行,当循环遇到一个耗时事件,它不会停下来等待,而是会跳过该事件继续往下执行,当不耗时的事件处理完了,再回过头来查看耗时事件的结果。所以,耗时事件不会阻塞循环,而在耗时事件之后的事件也就有机会被执行。
很容易发现,这种基于事件的异步模型比较适合 I/O
密集型的耗时操作,因为 I/O
耗时操作,往往把时间浪费在等待对方传送数据或者返回结果,因此这种异步模型往往用于网络服务器并发。如果是计算密集型的操作,则应当尽可能利用处理器的多核,实现并行计算。
这和 Isolate
有什么关系呢?
Flutter 的 main
函数是被一个隔离域包裹起来的,可以称为 main Isolate
,其实每个 Isolate
中都会有一份独立的内存和一个事件循环以及事件循环队列,也会有 一个执行事件循环的线程。
看一下 Flutter 中的消息队列机制,消息队列采用先进先出:
将消息转换成具体的类型就是:
如果我们不新开一个 Isolate
,那么默认所有的代码都会运行在 main Isolate
之中,而它只对应了一条线程,这也就是为什么我们说 Flutter 是单线程的一个原因。
Isolate
翻译过来是隔离域,所谓隔离,隔离的是内存,内存都隔离了,对象直接也不能直接访问,所以也不会涉及到共享资源的问题,所以也就不需要考虑多线程的那些令人头疼的问题了。
(图片出自 Flutter异步编程-Isolate)
iOS 中也有类似的概念:Actor。
当然,Isolate
之间是可以相互通信的的,是通过消息传递的方式。
but...
并非所有的对象都满足传递条件,在无法满足条件时,消息发送会失败。
举个🌰...
如果你想发送一个 List<Object>
,你需要确保这个列表中的所有元素都是可被传递的。假设这个列表中有一个 Socket
,由于它无法被传递,所以你无法发送整个列表。
and...
一个 Isolate
在阻塞时不会对其他 Isolate
造成影响。
so...
其实可以看出 Isolate
与线程和进程的概念是近似的,不同的是:每个 Isolate
都拥有 独立的内存,以及 运行事件循环的独立线程。
一个直观的比较
下面给出的 Demo 是给出了直接使用 async/await
和使用了 Isolate
之后的一个区别:
上面按钮是使用了 Isolate
进行耗时操作,可以看到对 UI 几乎没有影响,而下面的按钮执行的是同样的操作,但是没有使用 Isolate
,而是直接使用了 async/await
,UI 直接就卡住了。
如何使用
有三种方式:
- Dart -
Isolate
的static Future<Isolate> spawnUri()
- Dart -
Isolate
的static 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 Isolate
的SendPort
对象
在 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
,不需要做任何改动就能得到这些性能的提升。
数据的双向流转
上面的 Demo 的数据都是从新 Isolate
流向 main Isolate
, 那么 main Isolate
如果向其余 Isolate
发送数据呢?当然也是通过 SendPort
。SendPort
和 ReceivePort
就是 Isolate
之间的消息传递通道。
但是一对 SendPort
和 ReceivePort
管道的数据是单向流转的,如果需要互相通信,那么就需要两根管道,利用 ReceivePort.listen
方法,就可以监听到 SendPort
所发送过来的数据了。
处理连续的流数据
其实流数据的处理也简单,只要在接收到一个流数据处理的结果之后,使用 Isolate.send
进行数据的发送即可。
使用场景
Isolate
也不能滥用,应尽可能多的使用 Dart 中的事件循环机制去处理异步任务,这样才能更好的发挥 Dart 语言的优势。对于什么时候使用 Future
和 Isolate
,一个最简单的判断方法就是根据任务的耗时来选择:
- 耗时几毫秒或者十几毫秒左右的,应使用
Future
- 其余耗时更多的应使用
Isolate
来实现
一些参考场景:
- JSON 解码
- 加密
- 图像处理:比如裁剪
- 网络请求:加载资源、图片
性能测试
测试了一下 Isolate
的性能,执行一个很简单的任务:
static Future aSingleTask(int i) async {}
然后使用 Xcode 的 instrument 对性能进行测试,分别创建对应 Isolate
个数的 Isolate
执行任务(使用 compute
),记录性能消耗如下表:
Isolate(个数) | CPU(%) | 内存(M) | CPU恢复时间(s) | 线程数(DarkWorker) |
---|---|---|---|---|
0 | 10 | 128 | 0 | 1 |
10 | 18 | 140 | 2 | 8 |
50 | 130及以上 | 143 | 5 | 8 |
100 | 130及以上 | 148 | 12 | 10 |
200 | 130及以上 | 173 | 22 | 8 |
500 | 130及以上 | 164 | 55 | 9 |
1000 | 130及以上 | 162 | 115 | 9 |
可以看到当 Isolate
的个数增多,CPU 是主要的瓶颈,而且个数越多,CPU 恢复正常的时间就越长,可以看到随着 Isolate
个数的增多,线程数也在增多,不过当然也会通过线程池进行复用,所以最大线程数不会太多。
另外,通过 TimeProfiler 可以可以看到 CPU 主要是在执行 pthread_start
,开启新线程:
通过观察,得出了几个点:
-
注意不能同时创建过多的
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 是如何实现的,我还没有看源码,这个就留到以后吧。
参考文章:
这是一个视频:
Isolates and multithreading in Flutter
OK,那么本文就结束了,如果你觉得本文对你有帮助的话,留个赞再走吧~
转载自:https://juejin.cn/post/7065146668076761125