为什么现在Web应用程序疯狂地使用await/async?

47

我来自后端/厚客户端背景,所以可能有所遗漏...但最近我查看了一个开源的JWT令牌服务器的源代码,作者们在每个方法和每一行上都大量使用了 await/async。

我知道这种模式的用途是为了在单独的线程中运行长时间运行的任务。在我的厚客户端时代,如果一个方法可能需要几秒钟,我会使用它,以免阻塞GUI线程...但绝对不会在需要几毫秒的方法上使用它。

这种过度使用 await/async 是你需要进行Web开发还是像Angular这样的东西吗?这是在JWT令牌服务器上,甚至不知道它与这些有什么关系。它只是一个REST终点。

把每一行都设置为异步,如何改善性能?对我来说,它会因为启动所有这些线程而降低性能,不是吗?


多线程编程的主要原因之一是为长时间IO操作提供解决方案。但实现整个IO异步模型可能是困难的任务,更不用说耗时了。.net为此提供了一个简单的解决方案Task,甚至更简化了两个新关键字awaitasync。当事情变得简单时,人们开始滥用它们,以便更简单化,因为他们在使用它们时不想再去思考它们了。 - Logman
3个回答

146
我知道这个模式的作用是什么...在单独的线程中运行长时间运行的任务。但这绝对不是这种模式的作用
请确保您非常清楚,等待操作并没有将操作放在新线程上。 等待安排剩余的工作作为高延迟操作的继续执行。
等待不会将同步操作变成异步并发操作。 等待使正在使用已经是异步的模型的程序员编写类似于同步工作流的逻辑。等待既不创建也不销毁异步性; 它管理现有的异步性。
启动新线程就像雇佣一个工人。 当您等待任务时,您并没有雇用工人来完成该任务。 您正在询问“此任务是否已完成?如果没有,请在完成时回调我,以便我可以继续执行依赖于该任务的工作。 同时,我要在这边做另一件事...”
如果你在做税务申报,发现需要一份来自公司的号码,但信件还未到达,那么你不会雇佣一个工人守在邮箱旁等待。你应该记录下你在税务申报中的进度,去做其他事情,当信件到达时,你可以继续之前的工作。这就是await。它是异步等待结果

这种过度使用 await / async 是 Web 开发或像 Angular 这样的项目必需品吗?

它用于管理延迟。

让每一行都变成异步的方式如何提高性能呢?

有两个方面的好处。首先,它确保应用程序在高延迟操作的环境下仍然保持响应。这种性能对于不希望应用程序卡死的用户非常重要。其次,它为开发人员提供了表达异步工作流程中数据依赖关系的工具。通过不阻塞高延迟操作,系统资源可以释放出来处理未被阻塞的操作。

对我来说,这会因为启动所有这些线程而降低性能,不是吗?

这里没有线程。并发是实现异步的一种机制,不是唯一的机制。

好的,如果我像这样编写代码: await someMethod1(); await someMethod2(); await someMethod3(); 那么应用程序会神奇地变得更加响应吗?
相比于什么更具响应性?相比于不等待调用这些方法吗?当然不是。相对于同步等待任务完成呢?是的,绝对是。
那就是我不理解的地方。如果你在最后等待了所有3个,那么是的,你正在并行运行这3个方法。
不不不。停止考虑并行化。这里不需要任何并行化。
从这个角度来考虑。你想做一个煎蛋三明治。你有以下任务:
- 煎鸡蛋 - 烤面包 - 组装三明治
三项任务。第三项任务依赖于前两项的结果,但前两项任务彼此之间并不依赖。因此,以下是一些工作流程:
  • 在平底锅里放一个鸡蛋。当鸡蛋在煎的时候,盯着它看。
  • 一旦鸡蛋做好了,把一些面包放进烤面包机里。盯着烤面包机看。
  • 一旦面包烤好了,把鸡蛋放在面包上。

问题是,你可以在煮鸡蛋的同时把面包放进烤面包机里。备选方案:

  • 在平底锅里放一个鸡蛋。设置一个闹钟,在鸡蛋做好时响起。
  • 把面包放进烤面包机里。设置一个闹钟,在面包烤好时响起。
  • 查看你的邮件。做税务。擦亮银器。不管你需要做什么。
  • 当两个闹钟都响起时,拿起鸡蛋和面包,把它们放在一起,你就有了一个三明治。

你看到为什么异步工作流程要更高效了吗?在等待高延迟操作完成时,你会完成很多事情。但你没有雇用煮鸡蛋和烤面包的厨师。没有新的线程!

我提出的工作流程将是:

eggtask = FryEggAsync();
toasttask = MakeToastAsync();
egg = await eggtask;
toast = await toasttask;
return MakeSandwich(egg, toast);

现在,将其与以下进行比较:

eggtask = FryEggAsync();
egg = await eggtask;
toasttask = MakeToastAsync();
toast = await toasttask;
return MakeSandwich(egg, toast);

你看到这个工作流程的不同了吗?这个工作流程是:

  • 把鸡蛋放在平底锅里并设置闹钟。
  • 做其他工作,直到闹钟响起。
  • 将鸡蛋从平底锅中取出;把面包放进烤面包机。设置闹钟......
  • 做其他工作,直到闹钟响起。
  • 当闹钟响起时,组装三明治。

这个工作流程效率较低,因为我们未能捕捉到烤面包和煮鸡蛋任务的高延迟和独立性。但它肯定比在等待鸡蛋煮熟时什么都不做更有效地利用资源。

整个事情的重点是:线程开销极高,所以不要启动新线程。相反,在执行高延迟操作时,让您拥有的线程更有效地工作。等待不是关于启动新线程的;而是关于在高延迟计算的世界中在一个线程上完成更多的工作。

也许计算正在另一个线程上执行,也许它被阻塞在磁盘上,无论如何,这不重要。关键是,await用于管理异步性,而不是创建异步性。
“我很难理解如何在没有并行处理的情况下进行异步编程。比如,在等待鸡蛋煮熟时如何告诉程序开始制作吐司,而不至于DoEggs()运行并发,至少在内部是这样的吗?”
回到类比中。你正在制作一个鸡蛋三明治,鸡蛋和吐司正在烹饪,所以你开始阅读邮件。当鸡蛋做好了一半时,你把邮件放在一边,把鸡蛋从火上取下。然后你回到了邮件。接着吐司烤好了,你就制作三明治。最后,在三明治制作完成之后,你继续阅读邮件。你如何在没有雇员的情况下完成所有这些工作,一个人读邮件,一个人煮鸡蛋,一个人烤面包片,一个人组装三明治?你只用一个工人完成了所有工作。
通过将任务分解成小块,记录哪些部分必须按照什么顺序完成,然后通过“协作式多任务处理”来完成这些任务。如今的孩子们拥有大型平面虚拟内存模型和多线程进程,认为这就是一直以来的情况,但我的记忆可以回溯到没有这些东西的Windows 3时代。如果你想让两件事情“并行”发生,那就要将任务分解成小部分,并轮流执行它们。整个操作系统都是基于这个概念构建的。
现在,你可能会看这个比喻并说:“好吧,但是有些工作,比如烤面包,是由机器完成的”,而且正是这种方式实现了并行性。当然,我不必雇用一个工人来烤面包,但我通过硬件实现了并行性。这才是正确的思考方式。硬件并行和线程并行是不同的。当您向网络子系统发出异步请求以从数据库中查找记录时,没有线程等待结果。硬件实现了远低于操作系统线程的并行性水平。
如果你想更详细地了解硬件如何与操作系统配合实现异步操作,可以阅读 Stephen Cleary 的 "There is no thread"。
因此,当你看到 "async" 时,不要认为是 "parallel",而应该认为是将高延迟操作拆分成小块。如果有许多这样的操作,它们的各个部分彼此不依赖,则可以在一个线程上 协作交错 执行这些操作的各个部分。
正如你所想象的那样,编写能够放弃当前工作、转而做其他事情,并无缝地回到之前工作的控制流非常困难。这就是我们让编译器完成这项工作的原因!"await" 的意义在于,它让你通过将异步工作流描述为同步工作流来管理这些异步工作流。在任何可以将此任务放置一旁并稍后返回的点上,都要写入 "await"。编译器会负责将您的代码转换为许多可以在异步工作流中调度的小块。
更新:

在您的最后一个示例中,有什么区别?


eggtask = FryEggAsync(); 
egg = await eggtask; 
toasttask = MakeToastAsync(); 
toast = await toasttask; 

egg = await FryEggAsync(); 
toast = await MakeToastAsync();?

我假设它同步调用它们,但是异步执行它们?我必须承认,我从来没有在单独等待任务之前费心去等待过。
没有区别。
当调用FryEggAsync时,无论是否出现await,都会调用它。 await是一个运算符。它对从调用FryEggAsync返回的东西进行操作。就像任何其他运算符一样。
再说一次:await是一个运算符,其操作数是一个任务。毫无疑问,它是一个非常不寻常的运算符,但从语法上讲,它是一个运算符,并且像任何其他运算符一样对值进行操作。
我再说一遍: await 不是你在调用站点上放置的魔法尘土,突然那个调用站点就被远程到另一个线程。调用发生在调用发生时,调用返回一个,而该值是对作为await运算符的合法操作数的对象的引用

所以是的,

var x = Foo();
var y = await x;

并且

var y = await Foo();

是同一件事,与...相同

var x = Foo();
var y = 1 + x;

并且

var y = 1 + Foo();

这两个东西是相同的。

所以我们再来看一遍,因为你似乎相信一个谣言,即 await 会导致异步。事实并非如此。

async Task M() { 
   var eggtask = FryEggAsync(); 

假设调用了M()。同步地调用了FryEggAsync。不存在异步调用的概念;你看到一个调用,控制权就会传递给被调用者,直到被调用者返回。被调用者返回代表将来可用的鸡蛋的任务FryEggAsync是如何做到这一点的?我不知道也不关心。我只知道我调用它,然后得到一个代表未来值的对象。也许该值在不同的线程上生成。也许它在这个线程但是在未来生成。也许它是由特殊目的的硬件生成的,比如磁盘控制器或网络卡。我不关心。我只关心我拿到一个任务。
  egg = await eggtask; 

现在我们将任务进行 await,并询问它“你完成了吗?”如果答案是肯定的,则将任务产生的值赋给 egg。如果答案是否定的,则 M() 返回一个代表“M 的工作将来会完成”的 Task。M() 的其余部分被注册为 eggtask 的继续,因此当 eggtask 完成时,它将再次调用 M() 并从 赋值给 egg 而不是从 开头 继续执行。M() 是一种可在任何点上恢复执行的方法。编译器会做必要的魔法使这成为可能。
现在我们已经返回。线程继续执行它所需的操作。在某个时候,蛋已经准备好了,因此调用 eggtask 的继续,导致再次调用 M()。它从离开的地方恢复执行:将刚产生的蛋赋值给 egg。现在我们继续前进:
toasttask = MakeToastAsync(); 

再次调用返回一个任务,然后我们:

toast = await toasttask; 

检查任务是否已完成。如果是,我们分配toast。如果不是,则我们再次从M()返回toasttask继续是*M()的剩余部分。

等等。

消除task变量没有任何实质性作用。值的存储被分配;只是没有名称。

另一个更新:

是否有理由尽早调用返回任务的方法,但尽可能晚地等待它们?

给出的示例类似于:

var task = FooAsync();
DoSomethingElse();
var foo = await task;
...

有人提出了这个观点,但是让我们先退一步。 await 运算符的目的是使用同步工作流程的编码约定构建异步工作流程。所以需要考虑的是这个工作流程是什么? 工作流程对一组相关任务施加顺序要求。
查看数据依赖关系是了解工作流程所需顺序的最简单方法。您无法在烤面包机中烤面包之前制作三明治,因此您必须在制作三明治之前从某个地方获取烤面包。由于 await 从已完成任务中提取值,因此在创建烤面包机任务和创建三明治任务之间必须有一个 await。
您还可以表示对副作用的依赖关系。例如,用户按下按钮,因此您想播放警笛声,然后等待三秒钟,然后打开门,再等待三秒钟,然后关闭门。
DisableButton();
PlaySiren();
await Task.Delay(3000);
OpenDoor();
await Task.Delay(3000);
CloseDoor();
EnableButton();

说出这句话毫无意义

DisableButton();
PlaySiren();
var delay1 = Task.Delay(3000);
OpenDoor();
var delay2 = Task.Delay(3000);
CloseDoor();
EnableButton();
await delay1;
await delay2;

因为这不是期望的工作流程。
因此,对你的问题的实际答案是:在实际需要该值的时候延迟等待是一个非常好的做法,因为它增加了安排工作的机会。但是你也可以走得太远;确保所实现的工作流程是你想要的。

1
我知道 await SomeAsyncMethod(); 本身不会神奇地创建另一个线程,但是可能 SomeAsyncMethod 内部使用了并行处理?我很难理解在没有某处使用并行处理的情况下如何进行异步编程。比如,如果没有 DoEggs() 在并发运行的话,你怎么告诉程序在等待鸡蛋的同时开始烤面包呢? - Abion47
1
@Logman:如果我们在延迟步骤上阻塞,那么是什么使玩家不断更新并刷新UI?为什么您认为“其他线程的资源”很重要?可能没有其他线程。您所说的“语法解决方案”是什么意思?我不确定为什么您认为我的示例(说明非数据相关工作流)不是我考虑到没有数据依赖性的数据流时所想的。 - Eric Lippert
1
@Logman:你的想法完全是错误的;我建议你学习一下异步延迟的工作原理,以及为什么在void函数中隐藏await是行不通的。考虑实际构建一个带有这些不同工作流程的小程序,你很快就会明白为什么你做出了错误的等价性比较。 - Eric Lippert
7
@EricLippert,这篇文章让我完全明白了“Await/Async”的含义。感谢您花费时间写出如此详尽的文章! - Alex Rohr
1
这就是StackOverflow发明的答案类型。太棒了,@EricLippert! - Nick Coad
显示剩余16条评论

3
通常情况下,这是因为一旦异步函数与其他异步函数相互配合,否则您就会失去异步性的好处。因此,调用异步函数的函数本身也变成了异步函数,并且它遍布整个应用程序,例如,如果您将与数据存储的交互设为异步,则利用该功能的事物也往往变为异步。当您将同步代码转换为异步代码时,您会发现,最好的方式是异步代码调用和被其他异步代码调用——一路到底(或者“向上”,如果您愿意的话)。其他人也注意到了异步编程的传播行为,并将其称为“具有传染性”或将其与僵尸病毒进行比较。无论是龟还是僵尸,异步代码确实倾向于驱动周围的代码也变成异步代码。这种行为固有于所有类型的异步编程,而不仅仅是新的async/await关键字。

来源:异步/等待 - 异步编程的最佳实践

2

这是一个演员模型的世界,真的...

我的看法是,异步/等待只是一种打扮软件系统的方式,以避免不得不承认,实际上很多系统(特别是那些具有大量网络通信的系统)更适合被视为演员模型(或更好的选择是通信顺序处理)系统。

使用这两种技术的整个重点在于等待多个任务中的任何一个完成,并在其中一个完成时采取必要的行动,然后返回等待状态。具体来说,您正在等待来自其他地方的消息,阅读它并根据内容采取行动。在*nix中,通常使用epoll()或select()调用进行等待。

使用await/async只是一种假装您的系统仍然是同步方法调用(因此熟悉),同时使其难以有效地应对不一致的顺序完成任务的情况。

但是,一旦您摆脱了您不再调用方法而只是来回传递消息的想法,这一切就变得非常自然。这非常接近“请做这个”,“没问题,这是答案”的事情,并且有许多这样的交互缠绕在一起。将其与一个大的WaitForLotsOfThings()循环包装起来只是明确地承认,您的程序将等待直到有许多其他程序与它通信并做出响应。

Windows为什么难以实现

不幸的是,Windows非常难以实现反应堆系统(“如果您现在读取该消息,您将获得该消息”)。Windows是自动化操作者(“您要我读取的那个消息?它已经被读取了。”)。这是一个重要的区别。

首先,我将解释反应堆和自动化操作者。

  • 反应堆“反应”于事件。例如,如果某个套接字准备好读取,仅在此时,“反应堆”模型程序才决定要读取什么以及要对其采取什么行动。
  • 而自动化操作者会主动决定当套接字准备好时要执行的操作,并承诺执行该操作。

使用反应堆,易处理意味着“停止侦听另一个演员”的消息-您只需从下一次等待时将该演员排除在您将侦听的列表之外(即下一次调用select()epoll())。

使用自动化操作者,这就更难了。如果read()套接字已经使用某种异步调用开始,并且在读取内容之前不会完成调用,如何遵守“停止侦听另一个演员”的消息?最近收到的指令让完成的read()成为有疑问的结果吗?

我有点挑剔。在动态连接的系统中,反应器非常有用,演员进入系统,再退出。如果您有一个固定的演员人口和通信链接,那么Proactor就很好。然而,考虑到Proactor系统可以在反应器平台上轻松实现,但反应器系统不能在Proactor平台上轻松实现(时间不会倒流),我发现Windows的方法特别令人恼火。
因此,无论如何,异步/等待仍然处于Proactor领域。
影响力
这已经感染了许多其他库。
C++的Boost asio也是Proactor,即使在*nix上,似乎主要是因为他们想要有一个Windows实现。
ZeroMQ是一个反应器框架,在Windows上受到一定限制,因为它基于对select()的调用(在Windows上只适用于套接字)。
对于Windows上的POSIX运行时的cygwin系列,他们必须通过每个文件描述符的线程轮询(是的,轮询!)来实现select()、epoll()等,以重新创建POSIX例程。Yeurk!他们实施该部分的电子邮件列表上的评论可以作为有趣的阅读材料。
演员并不一定慢
值得注意的是,“传递消息”这个短语并不一定意味着传递副本——有许多演员模型的公式,其中你只是传递消息的引用所有权(例如,在C#中的任务并行库的Dataflow部分)。这使它快速。我还没有开始查看Dataflow库,但它并不会让Windows反应器突然出现。它不会为您提供一个演员模型反应器系统,可以在各种数据承载器上工作,例如套接字、管道、队列等。
Windows 10的Linux运行时
因此,刚刚抨击了Windows和其劣质的Proactor架构,一个有趣的点是,Windows 10现在在WSL1下运行Linux二进制文件。我非常想知道微软如何实现了WSL1中潜在的select()、epoll()系统调用,因为它必须在套接字、串口、管道和POSIX领域中的所有其他文件描述符上运行,而Windows上的其他所有内容都无法做到这一点?我很想知道这个问题的答案。

有趣的评论,尤其是第一部分,但我发现你在没有任何真正介绍的情况下开始谈论proactor和reactor概念有点突兀。 - O'Rooney
1
@O'Rooney,更糟糕的是我一直把proactor和reactor搞错了。我已经纠正了这个问题,但我还会添加一些东西来缓解对它的突然跳入!敬请期待。 - bazza

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