Dart 语法基础-异步详解

Dart 语法基础-异步详解

接上一篇基础语法 ,接下来整理一下Dart的异步和并发。

本文是基于的dart sdk 是stable 2.4.0, devel 2.5.0-dev.1.0

文本分两部分

  1. 异步
  2. 并发

1. 异步

在JavaScript中,我们也可以使用异步编程模型的回调函数功能
在Dart中你也可以如此

但你会发现,回调函数在可读性、可维护性、以及执行先后顺序等方面均存在问题

于是在Dart中引入了FutureCompleter的概念

说到异步, 我们先来看看什么是同步。

1.1. 同步

举个例子



int getWinningNumber() {
  //获取2000以内的一个随机数,表示随机等待的时间
  int millisecsToWait = new Random().nextInt(2000);

  //获取1970-01-01T00:00:00Z (UTC) 到当前时间的毫秒数
  var currentMs = new DateTime.now().millisecondsSinceEpoch;

  //Wait
  var endMs = currentMs + millisecsToWait;
  while (currentMs < endMs) {
    currentMs = new DateTime.now().millisecondsSinceEpoch;
  }

  //返回中奖号码
  return new Random().nextInt(32) + 1;
}

void main(){
  for (var i = 0; i < 5; i++) {
    print(getWinningNumber());
  }
  print("end");

}

打印结果:

20
11
8
2
28
end

看到了吧, end 就是放在最后才会打印。 也就是说, 主线程(姑且称为主线程)是阻塞,卡死的。

1.2. Future简单介绍

async 库中有一个叫Future的东西。Future是基于观察者模式的。如果你熟悉Rx,你就很容易明白了。
首先先看一下下面的案例,看看它们之间有什么区别?

void testA() async{
    new Future(() {
      return "This is a doubi";
    });
  }

  Future testB() async{
    return new Future(() {
      return "This is a doubi";
    });
  }

  Future testC() {
    return new Future(() {
      return "This is a doubi";
    });
  }

打印结果: 都是逗比。

再看一下异步:
Future是支持泛型的, 例如Future, 通过T指定将来返回值的类型。
这个函数返回T类型的数据。 在匿名函数中的返回值就是Future的返回值。

当在main函数中 调用getFutureWinningNumber(), 我们通过Future调用then 方法订阅Future, 在then中注册回调函数, 当Future返回值时调用注册函数,当然也可以添加catch 来处理该函数中发生的异常。

void main(){

    for (var i = 0; i < 5; i++) {
    
      Future futureNumber = getFutureWinningNumber();
      
      print(futureNumber);///   打印结果
      
      futureNumber.then((int num)=>print(num));
      print("end");
    }
        
}
//并发在Dart中非常的简便,首先声明函数为Future对象,异步传回的结果为int
//计算中奖号码
Future getFutureWinningNumber() {
  //Completer就像是控制器,决定Future对象后续的流程,then或者catchError

  Completer numberCompleter = new Completer();

  //获取中奖号码
  Random r = new Random();
  int randomNum = r.nextInt(32) + 1;

  //这里用Timer模拟一个比较耗时的操作计算
  //异步计算的结果,调用函数complete送出结果
  new Timer(
      new Duration(milliseconds: 2000),

      () => numberCompleter.complete(randomNum)
  );

  return numberCompleter.future;
}

打印结果:

Instance of 'Future'
end
Instance of 'Future'
end
Instance of 'Future'
end
Instance of 'Future'
end
Instance of 'Future'
end
11
13
25
16
11

也就是说主线是不受影响的 直接答应了Future 和 每一次的执行后的end 标识符 。 在then中打印了,执行的结果,也就是在Completer中实现具体的打印方法。

//  2、上面的逻辑可以用wait,执行多个函数后,一并将结果返回为list
 void main(){
       Future.wait(
        [getFutureWinningNumber(),
        getFutureWinningNumber(),
        getFutureWinningNumber()
        ]).then((list) {
      list.forEach((num) => print(num));
    });
    print("future.wait end ");
 }

打印结果:

future.wait end 
16
15
9

是不是很惊喜,类似于Java语言中的CountDownLatch , 所有的异步方法执行完成之后, 然后执行某个操作。
应用场景就比如说 ,所有的图片都上传完毕,弹出消息框告诉用户, 已经上传完成。

 //3、异步,效果为顺序显示
  getFutureWinningNumber().then((num1) {
    print(num1);
    //return Future,继续获取中奖号码
    //并执行下一个then
    return getFutureWinningNumber();
  }).then((num2) {
    print(num2);
    return getFutureWinningNumber();
  }).then((num3) {
    print(num3);
  });

打印结果:

17
32
2

依次打印结果, 如果愿意 ,可以一直这么then下去,这个对于异步任务的时序性要求很高, 比如A、B、C三个任务, 如果想完成B任务, 必须完成A, 要想开始C必须完成B任务。

1.3 async/await介绍

思考一下,看了上面的案例,对于future的预期行为,如果我们希望在执行其他语句之前,先执行future,该怎么操作呢?
这就需要用到需要用到async/await。在test函数的花括号开始添加async关键字。我们添加await关键字在调用getTest方法之前,他所做的就是在future返回值之后,继续往下执行。我们将整个代码包裹在try-catch中,我们想捕获所有的异常,和之前使用catchError回调是一样。使用awiat关键字,必须给函数添加async关键字,否则没有效果。
注意:要使用 await,其方法必须带有 async 关键字。可以使用 try, catch, 和 finally 来处理使用 await 的异常!

Future test() async {
    try {
      String value = await getTest();
      print("测试----------"+value);
    } catch(e) {
      print('测试----------Error');
    }
    print('测试----------逗比是这个先执行吗');
  }

  Future getTest() {
    return new Future.delayed(new Duration(milliseconds: 2000),() {
      return "This is a doubi";
    });
  }

打印结果:

测试------This is a  doubi 
测试------逗比是这个先执行吗?

很明显在调用async 修饰的方法时候 添加await 关键字会让线程阻塞在这里,等待异步执行的结果。



// 在函数上声明了 async 表明这是一个异步方法
Future doAsync() async {
  try {
    // 这里是一个模拟请求一个网络耗时操作
    var result = await getHttp();
    //请求出来的结果
    print("print end ");
    return printResult(result);
  } catch (e) {
    print(e);
    return false;
  }
}
//将请求出来的结果打印出来
Future printResult(summary) {
  print(summary);
}

//开始模拟网络请求 等待 5 秒返回一个字符串
getHttp() {
  return new Future.delayed(Duration(seconds: 5), () => "Request Succeeded");
}

打印结果:

print end 
Request Succeeded

1.4 同步生成器

通过在函数主体前添加sync*可以快速标记该函数为同步生成器synchronous generator,解除很多手动定义可迭代类时复杂的公式化代码。假设我们要获取第一个自然数到n:


 void main(){
   var it = naturalsTo(3).iterator;
  while (it.moveNext()) {
    print(it.current);
  }
    
 }
 
 Iterable naturalsTo(n) sync* {
  print("Begin");

  int k = 0;
  while (k < n) yield k++;

  print("End");
}

打印结果:

Begin
0
1
2
End

当调用naturalsTo的时候,会立即返回Iterable(很像async函数立即返回Future),并且可以通过Iterable提取iterator。在有代码调用moveNext前,函数主体并不会执行。yield(生成)用于声明一个求值表达式,当第一次运行函数的时候,代码会执行到yield关键字声明的位置,并暂停执行,moveNext会返回true给调用者。函数会在下次调用moveNext的时候恢复执行。

当循环结束的时候,函数会隐式地执行return,这会使迭代终止,moveNext返回false。当然,作为一个专业的、实现了完整Iterable类API的迭代器和可迭代类来说,使用普通迭代器,这是非常冗长乏味的。

sync 和 * 可以分开,他们是不同的标记。如果你之前的代码使用了sync关键字,升级Dart并不会产生兼容性问题,sync并不是真正的预留字,并同样适用于async、await和yield。这些关键字仅在async函数和generator生成器函数中作为预留字(即:函数用async、sync*、async*标记)。

1.5.异步生成器:async*

异步生成器使用数据流来异步生成序列,通过在函数体前添加async*标识符来进行标记。这里同样用生成自然数来举例:


Stream asynchronousNaturalsTo(n) async* {
  print("Begin");

  int k = 0;
  while (k < n) yield k++;

  print("End");
}

void main(){
     asynchronousNaturalsTo(3).listen((v) {
    print(v);
  });

}

运行结果:

Begin
0
1
2
End

当运行asynchronousNaturalsTo的时候,会立即返回Stream,但函数体并不会执行,就像sync*生成器和async函数一样。一旦开始listen监听数据流,函数体会开始执行。当执行到yield关键字的时候,会将yield声明的求值表达式的计算结果添加到Stream数据流中。异步生成器没有必要暂停,因为数据流可以通过StreamSubscription进行控制。需注意的是,数据流不能重复监听。
打印结果:

Begin
Before Yield
0
Before Yield

上面的例子中,asynchronousNaturals用于获取小于3的自然数。async等标识符同样适用于get函数。同时,与async函数相关联的Stream可能会被pause暂停或cancel取消。如果被取消,控制权会转让给最近括起来从句末尾,即函数执行完毕。如果被暂停,函数会一直执行到yield声明的位置,然后暂停,直到Stream恢复。

1.6. yield*

之前的代码中,yield使虽然用起来确实有吸引力,但在写递归函数的时候,也可能遇到一些问题。下面是从大到小获取自然数的例子:

Iterable naturalsDownFrom(n) sync* {
  if (n > 0) {
    yield n;
    for (int i in naturalsDownFrom(n-1)) { yield i; }
  }
}
void main(){
    print(naturalsDownFrom(3));
}

运行结果:

(3, 2, 1)

由于每次调用sync*函数都会构建一个新的序列,因此在仅使用yield的情况下,只能遍历新构建的序列,通过yield将元素插入到当前序列中:

for (int i in naturalsDownFrom(n-1)) { yield i; }

上面的代码在功能上没什么问题,但我们来分析一下过程:
当n=3时,只执行了一次yield n;;
当n=2时,yield n;和yield i;分别执行了一次,并且执行的yield i;是上一层n=3时候的代码;
当n=1时,执行了一次yield n;,两次yield i;,执行的yield i;是n=2和n=3时候的代码。

在代码中添加提示信息可以看得更明白:

int level = 0;

Iterable naturalsDownFrom(n) sync* {
  level++;

  if (n > 0) {
    print("level: $level n:$n");
    yield n;

    for (int i in naturalsDownFrom(n-1)) {
      print("level: $level i:$i");
      yield i;
    }
  }
}

运行结果:

level: 1 n:3
level: 2 n:2
level: 2 i:2
level: 3 n:1
level: 3 i:1
level: 3 i:1
(3, 2, 1)

为避免和变量名混淆,用x表示自然数的位置(即第x个自然数),y表示yield i;执行的次数,即x=1时,y=0;x=2时,y=1;x=3时,y=2。y的值为0,1,2……x-1。

总的来说,因为递归将元素插入序列的原因,共执行了x(x-1)/2次yield i;,时间复杂度为O(n^2),很明显地存在性能问题,而yield则是为了解决此问题而设计的。yield后面必须跟其他的(子)序列,并且会将后面(子)序列的所有元素插入到当前正在创建的序列中。

于是,代码可以修改如下:

Iterable naturalsDownFrom(n) sync* {
  if ( n > 0) {
    yield n;
    yield* naturalsDownFrom(n-1);
  }
}

在sync函数中,子序列必须是一个Iterable可迭代对象;在async函数中,子序列必须是一个Stream数据流。子序列可以为空,当为空的时候,yield*会跳过该表达式,并且不会暂停。

2. 并发

在Dart中有个isolate, isolate 神似Thread , 但实际上两者有本质的区别。操作系统内内的线程之间是可以有共享内存的而isolate没有,这是最为关键的区别。

2.1 isolate定义

isolate是Dart对actor并发模式的实现。运行中的Dart程序由一个或多个actor组成,这些actor也就是Dart概念里面的isolate。isolate是有自己的内存和单线程控制的运行实体。isolate本身的意思是“隔离”,因为isolate之间的内存在逻辑上是隔离的。isolate中的代码是按顺序执行的,任何Dart程序的并发都是运行多个isolate的结果。因为Dart没有共享内存的并发,没有竞争的可能性所以不需要锁,也就不用担心死锁的问题。

An isolated Dart execution context. 这是文档对isolate的定义。

2.2 isolate之间的通信

由于isolate之间没有共享内存,所以他们之间的通信唯一方式只能是通过Port进行,而且Dart中的消息传递总是异步的。

2.3 isolate与普通线程的区别

我们可以看到isolate神似Thread,但实际上两者有本质的区别。操作系统内内的线程之间是可以有共享内存的而isolate没有,这是最为关键的区别。

2.4 isolate实现简述

我们可以阅读Dart源码里面的isolate.cc文件看看isolate的具体实现。
我们可以看到在isolate创建的时候有以下几个主要步骤:

  • 初始化isolate数据结构
  • 初始化堆内存(Heap)
  • 进入新创建的isolate,使用跟isolate一对一的线程运行isolate
  • 配置Port
  • 配置消息处理机制(Message Handler)
  • 配置Debugger,如果有必要的话
  • 将isolate注册到全局监控器(Monitor)

实际上底层还是使用了操作系统提供的OSThread。

将非常耗时的任务添加到事件队列后,仍然会拖慢整个事件循环的处理,甚至是阻塞。可见基于事件循环的异步模型仍然是有很大缺点的,这时候我们就需要Isolate,这个单词的中文意思是隔离。

简单说,可以把它理解为Dart中的线程。但它又不同于线程,更恰当的说应该是微线程,或者说是协程。它与线程最大的区别就是不能共享内存,因此也不存在锁竞争问题,两个Isolate完全是两条独立的执行线,且每个Isolate都有自己的事件循环,它们之间只能通过发送消息通信,所以它的资源开销低于线程。

比个例子吧:


void main(){

  print("main isolate start");
  create_isolate();
  print("main isolate end");
)

// 创建一个新的 isolate
create_isolate() async{
  ReceivePort rp = new ReceivePort();
  SendPort port1 = rp.sendPort;

  Isolate newIsolate = await Isolate.spawn(doWork, port1);

  SendPort port2;
  rp.listen((message){
    print("main isolate message: $message");
    if (message[0] == 0){
      port2 = message[1];
    }else{
      port2?.send([1,"这条信息是 main isolate 发送的"]);
    }
  });
}

// 处理耗时任务
void doWork(SendPort port1){
  print("new isolate start");
  ReceivePort rp2 = new ReceivePort();
  SendPort port2 = rp2.sendPort;

  rp2.listen((message){
    print("doWork message: $message");
  });

  // 将新isolate中创建的SendPort发送到主isolate中用于通信
  port1.send([0, port2]);
  // 模拟耗时5秒
  sleep(Duration(seconds:5));
  port1.send([1, "doWork 任务完成"]);

  print("new isolate end");
}

打印结果:

main isolate start
main isolate end
new isolate start
main isolate message: [0, SendPort]
new isolate end
main isolate message: [1, doWork 任务完成]
doWork message: [1, 这条信息是 main isolate 发送的]

运行后会创建两个进程,一个是主Isolate的进程,一个是新Isolate的进程,两个进程都双向绑定了消息通信的通道,即使新的Isolate中的任务完成了,它的进程也不会立刻退出,因此,当使用完自己创建的Isolate后,最好调用newIsolate.kill(priority: Isolate.immediate);将Isolate立即杀死。

3.总结

Isolate虽好,但也有合适的使用场景,不建议滥用Isolate,应尽可能多的使用Dart中的事件循环机制去处理异步任务,这样才能更好的发挥Dart语言的优势。

那么应该在什么时候使用Future,什么时候使用Isolate呢?
一个最简单的判断方法是根据某些任务的平均时间来选择:

方法执行在几毫秒或十几毫秒左右的,应使用Future

如果一个任务需要几百毫秒或之上的,则建议创建单独的Isolate
除此之外,还有一些可以参考的场景

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

4.参考资料

  • Dartlang社区
  • Dart 异步编程详解
  • Flutter Engine线程管理与Dart Isolate机制

你可能感兴趣的:(Flutter,Dart)