Dart中的async/await/then是如何工作的?

6
这可能是一个经常出现的问题,但我发现答案存在冲突,现在我不确定哪个才是正确的。我以为我理解了这个概念,然后我开始阅读所有这些答案,完全被搞糊涂了,所以我正在寻找一个明确而简单的答案,以便我能够轻松理解。
根据这个答案这篇文章await应该打断代码执行并实际上等待未来完成,然后继续按顺序执行其余的代码。它还建议这可能会阻塞主线程,在这种情况下这是合乎逻辑的。
另一方面,this, thisFlutter 团队的这个视频 表明 await 不会阻塞 代码执行的其余部分,它只是一种语法糖,用于注册回调函数以在未来完成时执行,这与 then 所做的事情相同。
现在,我尝试编写一个小程序来理解哪种方法是正确的,似乎第一种方法是正确的选择:
import 'dart:async';

// prints: 
// 1000+
// 1000+
void main() async {
  Stopwatch watch = Stopwatch();
  
  watch.start();
  
  await Future.delayed(Duration(seconds:1)).then((_){print(watch.elapsedMilliseconds);});
  
  print(watch.elapsedMilliseconds); 
  
}

相对于:

import 'dart:async';

// prints:
// 0
// 1000+
void main() async {
  Stopwatch watch = Stopwatch();
  
  watch.start();
  
  Future.delayed(Duration(seconds:1)).then((_){print(watch.elapsedMilliseconds);});
  
  print(watch.elapsedMilliseconds);
  
}

因此,我只想知道Flutter团队和一些人为什么建议await不会阻塞代码执行,以及这个概念如何实际运作。


我认为你把线程和事件循环混淆了。正如你所知,只有一个线程,但由于事件循环的存在,该线程似乎可以同时执行许多任务。它正在执行的任何任务都可以暂停并等待其他任务完成,因为它们会释放线程。这允许其他任务在它们的事件(定时器、网络、I/O 等)完成时交错执行。有关事件循环的更多信息,请参见:https://medium.com/dartlang/dart-asynchronous-programming-isolates-and-event-loops-bffc3e296a6a - Richard Heap
你唯一能够阻塞线程的方式是执行计算密集型任务(如计算10000位数的圆周率、计算数字签名、解码大型图像等)。这就是为什么Dart有隔离区的原因。这是你可以创建第二个线程来消耗所有核心进行计算密集型任务的方法,同时保持你的原始主隔离区能够响应事件。 - Richard Heap
3个回答

16
我认为有一些关于阻塞的误解。当你看到你的第一个例子时 - await 只会阻止您函数 中的其余代码 执行,而应用程序的其余部分仍将正常工作。
需要理解一件事:async/await 语法只是.then(callback) 语法的语法糖。它们都可以实现相同的功能,只是 async/await 更容易阅读、调试和理解。如您所见 - 在您的两个示例中,您都获得了相同的结果。问题是:您更喜欢哪种语法?
澄清一下 - 假设您想引入几个等待事件,每个事件等待1秒,并在每个事件之后编写消息。
您的第一个示例将如下所示:
import 'dart:async';

// prints 1000+
void main() async {
  Stopwatch watch = Stopwatch();
  
  watch.start();
  
  await Future.delayed(Duration(seconds:1));
  print(watch.elapsedMilliseconds); 

  await Future.delayed(Duration(seconds:1));
  print(watch.elapsedMilliseconds); 

  await Future.delayed(Duration(seconds:1));
  print(watch.elapsedMilliseconds); 

  
}

注意代码的易读性和易理解性。

现在,看第二个例子是如何改变以实现同样的效果的:

import 'dart:async';

void main() async {
  Stopwatch watch = Stopwatch();
  
  watch.start();
  
  Future.delayed(Duration(seconds:1)).then((_){
    print(watch.elapsedMilliseconds);
    Future.delayed(Duration(seconds:1)).then((_){
        print(watch.elapsedMilliseconds);
        Future.delayed(Duration(seconds:1)).then((_){
             print(watch.elapsedMilliseconds);
        });
    });
  });
}

它们都能实现同样的功能 - 但是第二个例子会让你的眼睛疼。

还有一个有趣的场景需要你考虑 - 如果你想要同时发生几件事情怎么办?这并不罕见 - 如果你需要从3个不同的服务器获取3个图像,你不会按顺序获取它们。你会想要同时发送所有3个请求,并等待它们全部完成。

使用async/await非常容易:

import 'dart:async';

// prints 1000+
void main() async {
  Stopwatch watch = Stopwatch();
  
  watch.start();
  
  var f1 = Future.delayed(Duration(seconds:1));
  var f2 = Future.delayed(Duration(seconds:2));
  var f3 = Future.delayed(Duration(seconds:3));

  await Future.wait([f1, f2, f3]);

  print(watch.elapsedMilliseconds); 

  
}
请注意,由于我们没有在每个 Future.delayed 前面放置 await,这意味着我们将启动延迟的 future,但我们不会等待它完成。
你会发现整个函数只需要3秒钟才能完成;因为所有三个计时器都在同时运行。Future.wait 将等待一组 futures 完成。
现在 - 很明显,在大多数情况下,您确实不需要 .then() 语法,但我认为它在更复杂的场景中仍然适用。
例如:您需要从3个服务器获取3张图片。每个服务器都有一个备份服务器;如果第一个服务器返回 null 作为结果 - 您需要从备份服务器获取资源。 此外:如果 Backup server 1 或 Backup server 2 返回 null,则需要调用 server 4 来获取单个图像。
您甚至可以绘制一个描述此内容的小图表。现在这就是 .then() 语法派上用场的地方 - 我们仍将其与 async/await 结合使用。我认为一旦您完全理解了这个例子 - 您几乎就理解了 async/await 和 .then()。让我们开始:
import 'dart:async';
import 'dart:math';

Future<int?> getImage(String server) async {
  var rng = Random();
  
  print("Downloading from $server");
  
  // we'll add random delay to simulate network
  await Future.delayed(Duration(seconds: rng.nextInt(5)));
  
  print("$server is done");
  
  // high chance of returning null
  if (rng.nextInt(10)<7) return null;
  return 1;
}

// prints 1000+
void main() async {
  
  Stopwatch watch = Stopwatch();
  
  watch.start();
  
  // get the image from server 1
  var f1 = getImage("Server 1").then((data) async { 
     return data ?? await getImage("Server 1 backup");
  });
  
  var f2 = getImage("Server 2").then((data) async { 
     return data ?? await getImage("Server 2 backup");
  });

  var f4=Future.wait([f1, f2]).then((data) async {
    if (data[0]==null || data[1]==null) {
       return [await getImage("Server 4")];
    } else {
       return data;
    }
  });
  
  var f3 = getImage("Server 3").then((data) async { 
     return data ?? await getImage("Server 3 backup");
  });

  await Future.wait([f3, f4]);

  print("elapsed ${watch.elapsedMilliseconds} ms"); 
  
}

新的一点是: .then() 将返回一个 future 对象 - 您仍然可以使用 await 关键字等待。告诉你它是同样的东西....

如果没有 .then() 语法,您将需要创建一个更多的异步函数来处理此操作,使您的代码变得更加复杂和难以阅读。使用 .then() 语法,代码只是稍微更易于管理。再次看到-.then() 和 async/await 实际上是同样的东西...

标准的 async/await 在线性情况下非常有用(就像我展示的多个 Future.delayed 示例)。但是,当您进入可以通过具有运行在并行的多个分支的图形描述的复杂场景时,.then() 就会派上用场。

编辑 - Dart 是单线程的

关于 Dart 是单线程的,可以这样考虑:您的代码在 Dart 引擎(或 Dart VM)内部运行,而此代码确实是单线程的。但是,任何对外部世界的调用都将并行运行(调用远程服务器,甚至调用本地硬盘,调用同一主机上的其他进程(如操作系统),以及调用类似于我例子中的定时器)。

就像我上面的例子一样:我调用了 3 个远程服务器来获取某些东西,并且我链接了 3 个不同的回调,每个调用一个。而“外部世界”的事情 - 调用服务器 - 真的是并行发生的。Dart 的单线程仅保证在任何给定时间点只执行代码的一行。

如果您来自 Java 背景,则会知道在 Java 中同步多个线程有多么困难:这就是代码通常会出错的地方。在 Dart 中,您无需担心此问题。真正的性能优化是任何在 Dart VM 外发生的事情实际上都在并行运行 - 而 Dart 会为您处理它。

现在这是如何工作的:事件循环。那是一个小的 Dart 引擎,跟踪所有您的远程服务器调用,准备好时回调您的回调过程。事件循环是处理您的代码一次处理一个请求的程序...


非常感谢您详细的回答。然而,我不同意您的观点,即我的两个示例产生相同的结果。我编辑了第一个示例中的代码,并将.then()链接到future,这使得程序打印1000+两次,而不是0和1000+。不过,您的最后一个示例很好。我可以理解我们可以使用.then()进行回退场景或者在then子句中使用第一个future的结果来做其他事情。我的唯一问题是:如果Dart是单线程的,那么如何继续执行函数外部 - 正如您所提到的? - moazelshebly
你是对的 - 在这两种情况下,确切的输出不会完全相同。问题是:逻辑上你想要实现什么?如果你的目标是最后一个打印语句打印程序的总运行时间 - 那么第一种方法是正确的。或者换个方式理解:如果有依赖关系,所以第二次打印只能在第一次完成后才能运行 - 那么第一种方法是正确的。如果没有依赖关系 - 那么逻辑上两种方法都可以;但是在第二种方法中,你提前执行了第二个打印命令 - 从而优化整个过程。 - Andrija
我忘记回复你的评论了,因为我正忙着写我的答案。你的解释和答案帮助我理解了这一切是如何工作的。它确实基于你最终想要实现什么以及你的目标是什么。你必须真正理解你想让代码在程序中如何流动,并且深入了解事件循环的工作原理。只有这样,你才能实现你想要的东西。谢谢你的好答案! - moazelshebly
非常感谢这些非常好的解释,以及@moazelshebly的自我回答。我特别喜欢你提到的那部分,@andrija,即当存在依赖关系时可以使用then。然而,当等待函数返回Future<void>时,似乎无法应用then,因为它不接受这样的函数作为输入。所以,为了涵盖这种特定的用例,似乎需要使用whenComplete而不是then。这样正确吗? - dalonsoa
不需要那样做;'then' 可以很好地处理 Future<void>。 - Andrija

1
安德里亚的回答在技术上是正确的。然而,我仍然需要认真思考才能理解它的工作原理,这就是为什么我会尝试简化问题,以便任何可能有相同问题的人都能够理解。
假设您有一个Dart程序; 显然有一个main()函数。 我们的程序调用两个函数:foo()bar()
函数foo()执行一些异步工作,例如网络调用:
Future<void> foo() async{
  Stopwatch watch = Stopwatch();
  
  watch.start();
  
  await Future.delayed(Duration(seconds:1));
  
  print(watch.elapsedMilliseconds);
}

而函数bar()是一个执行一些同步代码的普通函数:

void bar() {
  print("Some synchronous code");
}

现在,假设你的 main() 看起来像这样:

void main() {
  foo();
  bar();
}

主程序开始并调用了foo(),在main()中未使用await的情况下,我们遇到了foo()中的await,程序会这样说:"哦!我不应该延迟执行main()中的其余部分。我需要注册一个回调函数,在异步工作完成时执行该回调,并继续执行main()的其他部分"。 foo()被从调用栈中弹出,然后调用bar()并打印 "一些同步工作",然后也被弹出调用栈。同时,在foo()中的异步工作完成并发出完成信号。事件循环捕获到信号,并回到执行foo()中的其余代码(或回调中的代码,如果我们使用.then()的话;当然,如果主线程没有忙碌)。
简单地说,这就是所发生的事情。正如Andrija所建议的那样,await阻塞了同一函数中的其余代码的执行;您程序的其余部分将正常运行。如果我们在main()中使用await等待foo(),则main()中的执行也会被阻塞,直到foo()中的异步工作完成,而这不是我最初想到的情况。
我的想法是main()中的代码也会根据foo()中的await被延迟执行,但正如我们所看到的那样,情况并非如此。

太准确了!解释得非常好!我喜欢你说“我仍然需要认真思考” - 你可以阅读尽可能多的教程或StackOverflow答案 - 但除非你真正“认真思考”,否则你将无法理解。还有一个建议 - 我意识到我最初会编写有错误的代码,而我从不知道我的逻辑是错的,还是我处理异步/等待的方式有误。我意识到 - 如果我在每个异步调用上都放置等待,它就允许我专注于我的逻辑。在它工作后,我会开始通过删除等待并找出如何利用事件循环来优化性能。 - Andrija
太棒了!下次我写异步代码时会尝试应用这个技巧。谢谢 :) - moazelshebly

0

实际上,您这两个函数的结果是相同的,让我更清楚地解释一下...

当调用异步函数时,它们只是不会阻塞应用程序的其他部分进行渲染。我们正在执行的任何操作都将被延迟,但其余部分将按原样工作。

现在让我们来看看您的示例

import 'dart:async';

// prints 1000+
void main() async {
  Stopwatch watch = Stopwatch();
  
  watch.start();
  
  await Future.delayed(Duration(seconds:1));
  
  print(watch.elapsedMilliseconds); 
  
}

在上面的示例中,您只传递了延迟时间而没有传递回调函数。因此,它将其余部分视为回调函数,该函数将在完成持续时间后被调用。现在你正在做什么,你告诉你的函数等待一个持续时间,然后再执行后续代码execute
所以结果是1000+ 在下面的示例中:
import 'dart:async';

// prints:
// 0
// 1000+
void main() async {
  Stopwatch watch = Stopwatch();
  
  watch.start();
  
  Future.delayed(Duration(seconds:1)).then((_){print(watch.elapsedMilliseconds);}); // prints 1000+
  
  print(watch.elapsedMilliseconds); // prints 0
  
}

你正在正确地将一个callback分配给Future。现在Future只会保留它的callback,然后让其他部分完成。

这就是为什么它首先打印0,然后经过一秒钟的delay后再打印1000+的原因。

FutureFuture delay有不同的工作流程,这可能不是使用await的正确方式。


然而,如果你在第一个例子中分配一个回调函数来打印elapsedMilliseconds,它将会打印:1000+,然后再打印1000+。这表明,如果你不使用await,你实际上并没有阻塞代码执行。另一方面,如果你使用await,它将首先打印0,然后是1000+,这证明了代码执行被中断了。 - moazelshebly
你应该通过链接查看Future的详细信息和用例,除了示例之外,它还会让你知道如何正确使用Future,同时也要考虑你是否以正确的方式使用Future。请查看 https://dart.dev/codelabs/async-await - Diwyansh

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接