Flutter 异步模型(1)

本文首发于语雀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
image.png

这里Dart Pad这个工具没法使用scheduleMicrotask这个方法,所有用Future.microtask代替,结果也是符合预期的。可以看到第一个then里面就调度了微任务,为什么没有立马执行而是执行了后续的then呢?这里then的内容是会被优先执行完的,因为此时Future已经执行完成了,需要立马进行回调,不能进行额外的等待,所以看起来几个then是在一起执行的。但是这种情况下,还和Future.value和 future.then这两种情况不一样,这个例子所有的内容都是会在Event Queue中执行的。

你可以把他们想象成,所有的then的代码内容都在Future的耗时任务回调中,会在调度的时候一起被放到事件队列。

但是……又有特殊情况了,如果then返回了一个Future,那么后续的then是不会被立马执行的,而是排在这个Future之后的。


image.png
image.png

catchError

如果Future发生了异常,则需要使用catchError来捕获。


image.png
image.png

whenComplete

类似于Java的finally,无论成功和失败总会调用到的一个方法。


image.png

timeout

Timeout接受一个Duration类型的值,用来设置超时时间。如果Future在超时时间内完成,则就返回原Future的值,如果到达超时时间还没有完成,就是抛出TimeoutException异常,当然,如果设置了onTimeout参数,就会以设置的返回值返回,不会产生异常。

Future<T> timeout(Duration timeLimit, {FutureOr<T> onTimeout()?});

总结

未完待续。
干就完了。

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
平台声明:文章内容(如有图片或视频亦包括在内)由作者上传并发布,文章内容仅代表作者本人观点,简书系信息发布平台,仅提供信息存储服务。

推荐阅读更多精彩内容