本文首发于语雀https://www.yuque.com/hysteria/oemyze/cusxgq,转载请注明出处。
在学习Java的时候,多线程是我们望而却步的东西,但是接触了Dart之后,发现它是单线程。但其实这个单线程的运行模型也包含非常多的内容在里面,同样让人不想继续看下去。(回家种地警告)
但是作为Flutter中重要的一部分,我们必须要研究明白才能深入其整个宏观世界,因此这个系列,将从几部分来展开分析一下Flutter中的异步编程。
这里我分成两大部分来分析这个事情。第一部分是Dart的异步模型,第二个是上层封装。
异步模型分析
我们知道在一个稍微复杂点的程序运行时,总是会伴有一些网络,IO的操作。而在GUI系统中,如果这类耗时操作被放到主线程来执行,那么用户的操作就会无法及时响应,这个肯定是不能够接受的。而就算在Server工程中,如果用单线程的处理请求也是该被劝退了。而多线程带来的问题也是非常多,这就涉及到更多的,如同步锁问题,线程池等等……所以多线程在各自的技术栈中都是非常复杂的一部分。而今天的主角Dart,偏偏就选择了单线程。
这个对Dart开发者实在是太友好了,不用考虑太多关于多线程的问题就可以完成复杂的异步操作。但是话又说回来,如果是单线程,上面说的GUI问题岂不是就出现了么?其实不然,我们可以继续往下看。
首先我们来考虑一个问题。多线程模型里,实现GUI交互是怎么样的。这里以点击按钮请求数据更新界面举例。
主线程点击按钮 -> 创建子线程进行网络请求 -> 线程通信发送数据 -> 更新GUI
如上所示,我们说常见的Hander其实就是线程通信方式的一种。那么在单线程模型中,Dart,或者JavaScript则是借助于单线程循环模型来实现这个操作的。
先不说这个模型是啥样子,到这里很多同学就开始有疑问了。这不卡主线程?耗时操作怎么办的啊喂。但其实仔细想想,我们并不是每时每刻都在与界面进行交互,也并不是无时无刻在进行网络与IO操作,这就决定了程序在大部分时间都是空闲的。既然如此,那么用户交互,界面绘制与网络请求就能够被安排在一个进程中。这时候你可能又会说了,就算可以,那他在网络请求的时候遇到用户交互事件怎么办?那岂不是还是不能响应。这就不得不提到操作系统中一个非常重要的概念了 -- "时间片"。也就是说,操作系统不会让某个线程无休止的运行直到结束,而是将任务切成不同的时间片,某一时刻运行一个线程的其中一片。给人造成多线程并行执行的假象,这其实也就是“并发”的概念。这里说的是多线程,那么我们继续往微观角度想,如果一个线程里的n个任务单元可以如此,那岂不是就可以给人一种多个任务在一起运行的假象呢?嗯,有人比你先想到了,于是就有了协程。那么先不说Dart里面这个叫不叫做协程,但是结论就是这样了。
运行为什么不卡顿的问题解决了,那么还有一个问题。单线程模型能够利用好多核的能力么?这个后面会做解答。
Dart异步模型
接下来就是大家看过很多次的异步模型了,这里我从别的文章上“借鉴”了一张图。
image.png
从图中可以看出模型中有两个队列,事件队列(Event Queue)与微任务队列(Microtask Queue)。而这个模型的运行规则是。
- 启动App
- 首先执行main方法里的代码。
- main方法执行完成之后,开始遍历执行微任务队列,直到微任务为空。
- 继续向下执行事件队列,执行完一个就查询微任务队列是否有可以执行的微任务
- 然后两个队列的执行就一直按照这样的循环方式执行下去,直到App退出
那么相比到这里大家开始疑问什么样的叫做微任务,什么样的又可以称为事件?下面就解释一下这两种的区别,以及为什么要设计两个队列。
微任务
微任务在图中是一个优先级非常高的角色,可以看到。每次都是微任务优先执行,一有微任务,不过是先来的后来的都需要无条件执行。微任务可以通过下面的方式加入。
scheduleMicrotask(() => print('This is a microtask'));
考虑到这个任务的优先级比较高,我们平时也不会用这种方法来执行异步任务。Flutter内部也只有几处用到了这个方法(比如,手势识别、文本输入、滚动视图、保存页面效果等需要高优执行任务的场景)。
事件
事件的范围就广了一点,比如网络,IO,用户操作,绘图,计时器等。而这个事件还有一个重要封装,就是Future,从名字可以看出含义就是未来执行的一段代码。
为什么单线程?
结合单线程模型和之前说的协程部分我们可以大概知道了Dart的运行规则。这个时候我们大概可以解答之前留下的疑问了,Dart的单线程模型怎么发挥CPU多核优势呢?
下面是我个人的一点见解,如果有不同的观点可以指出。
其实我们看JavaScript为什么用单线程模型,也就知道Dart为什么也要用了。Dart诞生是为了“Battle”JS的,但目前看来应该是失败了。我们从网上查阅资料,就会发现这样的段落。
JavaScript的单线程,与它的用途有关。作为浏览器脚本语言,JavaScript的主要用途是与用户互动,以及操作DOM。这决定了它只能是单线程,否则会带来很复杂的同步问题。比如,假定JavaScript同时有两个线程,一个线程在某个DOM节点上添加内容,另一个线程删除了这个节点,这时浏览器应该以哪个线程为准?
为了利用多核CPU的计算能力,HTML5提出Web Worker标准,允许JavaScript脚本创建多个线程,但是子线程完全受主线程控制,且不得操作DOM。所以,这个新标准并没有改变JavaScript单线程的本质。
这两段话说到了两个事。一个是多线程容易发生并发问题,第二个是JS也尝试利用多核CPU的能力,但是也只是阉割版的多线程。因此我们也就可以大致类比到,Dart其实也是基于此考虑的,毕竟Dart生来是想用在Web的,但是最后用在了移动端。但其实恰好所有的GUI系统都不是特别需要非常多的线程的,(相信许多Android开发者都没怎么用过多线程的锁之类的吧),最常见的也就是2,3个线程在做事情。但是退一万步讲,就真的是非常复杂的,要开许多个线程怎么办?也就是说情况越极端,对CPU的利用能力与原生差异就越明显。这个时候Dart其实也考虑过了,就是它还是运行你创建线程的。
这个“线程”叫做isolate。
isolate
isolate在这里翻译成“隔离”,从名字就可以看出来,不同的isolate都是独立的。这个与你说认知的Thread是有差异的。所以Dart还是保守了。难道是怕写不出DougLea老爷子那么优雅的代码?没有了多线程的共享问题,也就不用写各种同步锁,CAS原子等机制,但随之带来的问题就是通信了。isolate的通信是靠着port的,这里不展开说。所以更像是 Future是线程,isolate是进程。
到这里我们可以抛下的疑问都解决了。
可以看到,单线程模型与多线程模型没有孰好孰坏,只有在他们各自擅长的场景才能展示出自己最大的的性价比。也正是如此,我们在Android开发中用多线程的时候,也不是盲目的去new Thread,而是优先会考虑线程池。大部分情况下,适当的线程可以更好的利用CPU不会消耗很大的资源,而且也能够得心应手的处理完所有任务。不会造成资源的浪费。
平时开发业务很少用到isolate,一方面是它通信很麻烦,另一方面我们并没有太大的需求要用这个,但是如果真的有需要的场景,其实是不建议盲目用一堆Future的,这样除了代码简单之外,没有什么好处。
Future的分析(1)
前面说到Future其实是对事件的上层封装,但是实际的运行过程也有不一样的表现。为什么这么说,可以看到下面的分析。首先我们从Future这个类说起。
首先我们看到,Future是有几个构造方法的,此外没有在这个图片上表现出来的是他的默认构造,下面分别来说一下这几个构造方法。
image.png
Future(FutureOr<T> computation())
默认构造函数,此函数接受一个返回FutureOr类型的函数类型
factory Future(FutureOr<T> computation()) {
_Future<T> result = new _Future<T>();
Timer.run(() {
try {
result._complete(computation());
} catch (e, s) {
_completeWithErrorCallback(result, e, s);
}
});
return result;
}
通过这个方法创建的Future,最后会被添加到EventQueue中。而这里FutureOr这个类其实就是以这个名字来告诉你,可以返回包括Future在内的所有类型,其实并没有相应的实现。这里由Timer.run来调度了computation参数。
Future.microtask(FutureOr<T> computation())
微任务构造。
factory Future.microtask(FutureOr<T> computation()) {
_Future<T> result = new _Future<T>();
scheduleMicrotask(() {
try {
result._complete(computation());
} catch (e, s) {
_completeWithErrorCallback(result, e, s);
}
});
return result;
}
从代码看,不一样的地方在于这次是使用scheduleMicrotask来调度的,我们前面说过,通过这个方法就可以创建一个微任务,因此这个computation方法将会被添加到微任务队列中执行。
这里来一个小例子看一下谁更快被执行。
void main(){
Future(() => print("event task!"));
Future.microtask(() => print("micro task!"));
}
image.png
事实证明,micro task确实会优先被调度。
Future.sync(FutureOr<T> computation()) {}
看名字是一个同步的方法。
factory Future.sync(FutureOr<T> computation()) {
try {
var result = computation();
if (result is Future<T>) {
return result;
} else {
return new _Future<T>.value(result as dynamic);
}
} catch (error, stackTrace) {
……
}
}
这个方法是直接取了computation结果,如果结果是Future,就直接返回,否则使用value方法调度。
这里可以再做个对比。
void main(){
Future(() => print("event task!"));
Future.microtask(() => print("micro task!"));
Future.sync(() {
print("sync task");
});
}
image.png
由于sync方法的参数提前被执行,就相当于在main方法层面执行的,这个顺序也与我们上面提到的线程模型完全相符。
Future.value([FutureOr<T>? value])
这个方法上面提到过了,而他的实现也比较特殊。
factory Future.value([FutureOr<T>? value]) {
return new _Future<T>.immediate(value == null ? value as T : value);
}
_Future.immediate(FutureOr<T> result) : _zone = Zone._current {
_asyncComplete(result);
}
void _asyncComplete(FutureOr<T> value) {
if (value is Future<T>) {
_chainFuture(value);
return;
}
_asyncCompleteWithValue(value as dynamic);
}
void _asyncCompleteWithValue(T value) {
_setPendingComplete();
//这里~
_zone.scheduleMicrotask(() {
_completeWithValue(value);
});
}
可以看到这个方法,如果是返回非Future类型,则最终调用了scheduleMicrotask将任务调度。这样做也是有其中的原因的,因为非Future的value不需要执行,也就认为传入即完成,则需要迅速执行其后的链式方法,需要用到微任务队列。
与此情况类似的一种是这样的。如果我们提前建立了一个Future,并且这个Future已经执行完成的时候,其后的then的调用则会被微任务队列调度。
var future = Future(() => print("future"));
future.then((value) => print("future then"));
Future.delayed(Duration duration, [FutureOr<T> computation()?])
从函数名字中就可以看出来,这是一个延时执行的Future。
factory Future.delayed(Duration duration, [FutureOr<T> computation()?]) {
……
_Future<T> result = new _Future<T>();
new Timer(duration, () {
if (computation == null) {
result._complete(null as T);
} else {
try {
result._complete(computation());
} catch (e, s) {
_completeWithErrorCallback(result, e, s);
}
}
});
return result;
}
相信大家已经可以猜到,内部与默认构造函数几乎一样,只是利用了Timer的计时功能,时间到了之后开始调度。
Future.error(Object error, [StackTrace? stackTrace])
这个方法不太常用,是创建一个错误的Future,内部同value方法,也是由scheduleMicrotask进行调度的,至于这个方法存在的意思是什么,我也不太清楚了。
上面是一些基础的构造/工厂函数,用来创建Future,但是Future也提供了一些静态的方法,用于创建更高级的表现形式。
Future.wait
这个方法用来等待多个Future的结果,如果其中一个发生了问题,那么就直接失败。但是这个表现由eagerError参数来控制
Future.foreach
这个方法其实就算是工具了,类似于RX里的一些工具方法,循环遍历列表,然后每次读取到一个数据,就调用一下回调。
Future.forEach({1,2,3}, (num){
return Future.delayed(Duration(seconds: num),(){print(num);});
});
Future.any
返回第一个Future执行完的结果,不管这个结果是正确与否。
static Future<T> any<T>(Iterable<Future<T>> futures) {}
Future.doWhile
循环执行回调操作,直到它返回false
static Future doWhile(FutureOr<bool> action())
Future的分析(2)
上面的非常大的篇幅来分析了几个Future类里的API,我们在平时的开发中也就是利用这些API来完成的。但这些API只是用来创建Future,如果我们使用Future发起一个网络请求,怎么能拿到请求返回的结果呢?这里就要用到我们的处理结果相关方法。而且这些方法的也会有一些配合的规律,一起来看下。
then
前面提到过then这个方法。
他就是用来处理结果的,当我们的耗时任务执行完成的时候,then就会被调用,而且多个then链在一起的话,还会一起调用。
//签名
Future<R> then<R>(FutureOr<R> onValue(T value), {Function? onError});
//使用
Future(() => print("task1"))
.then((value) {
print("task2");
Future.microtask(() => print("micro task"));
})
.then((value) => print("task4"))
.then((value) => print("task5"));
image.png
timeout
Timeout接受一个Duration类型的值,用来设置超时时间。如果Future在超时时间内完成,则就返回原Future的值,如果到达超时时间还没有完成,就是抛出TimeoutException异常,当然,如果设置了onTimeout参数,就会以设置的返回值返回,不会产生异常。
Future<T> timeout(Duration timeLimit, {FutureOr<T> onTimeout()?});
总结
未完待续。
干就完了。
网友评论