【问题标题】:Why are web apps going crazy with await / async nowadays? [closed]为什么现在 Web 应用程序会因等待 / 异步而疯狂? [关闭]
【发布时间】:2016-09-16 00:18:23
【问题描述】:

我来自后端/厚客户端背景,所以也许我遗漏了一些东西......但我最近查看了开源 JWT 令牌服务器的源代码,作者对等待 / 异步感到疯狂。就像每个方法和每一行一样。

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

对于 Web 开发或 Angular 之类的东西,您需要过度使用 await / async 吗?这是在 JWT 令牌服务器中,所以甚至看不到它与其中任何一个有什么关系。这只是一个 REST 端点。

如何使每一行异步来提高性能?对我来说,启动所有这些线程会降低性能,不是吗?

【问题讨论】:

  • 多线程编程的主要原因之一是为长 IO 操作提供解决方案。但是实现整个 IO 异步模型可能是一项艰巨的任务,更不用说耗时了。 .net 为您提供了这个Task 的简单解决方案,并进一步简化了两个新关键字awaitasync。当事情变得简单时,人们开始滥用它们以实现更简单的目的,因为他们不想在使用它们时再考虑它们。

标签: c# multithreading asynchronous


【解决方案1】:

我知道该模式的用途...在单独的线程中运行长时间运行的任务。

这绝对不是这个模式的用途

Await 将操作放在新线程上。确保您非常清楚。 Await 将剩余工作安排为高延迟操作的延续。

Await 不会将同步操作变成异步并发操作。 Await 使正在使用已经异步的模型的程序员能够编写类似于同步工作流的逻辑。 Await 既不创建也不破坏异步;它管理现有的异步。

启动一个新线程就像雇用一个工人。当您等待一项任务时,您并不是在雇用工人来完成该任务。你在问“这个任务已经完成了吗?如果没有,完成后给我回电话,这样我就可以继续做依赖于该任务的工作。与此同时,我要去这里做另一件事.. 。”

如果您在报税时发现您需要工作中的号码,而邮件尚未到达,则您不要雇用工作人员在邮箱旁等待。你记下你的税收,去完成其他事情,当邮件来的时候,你从你离开的地方继续。那是等待。它异步等待结果

对于 Web 开发或 Angular 之类的东西,您需要过度使用 await / async 吗?

这是为了管理延迟。

如何让每一行都异步来提高性能?

有两种方式。首先,通过确保应用程序在具有高延迟操作的世界中保持响应。这种性能对于不希望他们的应用程序挂起的用户来说很重要。其次,通过为开发人员提供在异步工作流中表达数据依赖关系的工具。通过不阻塞高延迟操作,系统资源被释放以用于未阻塞操作。

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

没有线程。并发是一种实现异步的机制;它不是唯一的。

好的,如果我编写如下代码:await someMethod1();等待一些方法2();等待 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);

您知道该工作流程有何不同吗?这个工作流程是:

  • 在锅里放一个鸡蛋,然后设置闹钟。
  • 在闹钟响起之前去做其他工作。
  • 把鸡蛋从锅里拿出来;把面包放在烤面包机里。设置闹钟...
  • 在闹钟响之前继续做其他工作。
  • 当闹钟响起时,组装三明治。

此工作流程效率较低因为我们未能捕捉到 toast 和 egg 任务具有高延迟且独立的事实。但它肯定比在等待鸡蛋煮熟时无所事事更有效地利用资源。

整个事情的重点是:线程非常昂贵,所以不要启动新线程。相反,通过在执行高延迟操作时将其投入工作来更有效地利用线程。 Await 不是关于启动新线程的;它是关于在具有高延迟计算的世界中在一个线程上完成更多工作。

也许计算是在另一个线程上完成的,也许它在磁盘上被阻塞了,无论如何。没关系。关键是,等待是为了管理异步,而不是创建它。

我很难理解如何在不使用并行性的情况下实现异步编程。比如,在没有 DoEggs() 至少在内部同时运行的情况下,你如何告诉程序在等待鸡蛋的同时开始吐司?

回到类比。您正在制作鸡蛋三明治,鸡蛋和吐司正在烹饪,因此您开始阅读邮件。鸡蛋煮好后,你的邮件已经完成了一半,所以你把邮件放在一边,把鸡蛋从火上拿下来。然后你回到邮件。然后吐司完成,你做三明治。然后你在三明治做好后读完你的邮件。 您是如何做到这一切的,而无需雇用员工,一个人看邮件,一个人煮鸡蛋,一个人做吐司,一个人组装三明治?你一个人就做到了工人。

你是怎么做到的?通过将任务分解成小块,注意哪些部分必须按什么顺序完成,然后合作处理多个任务这些部分。

如今拥有大型扁平虚拟内存模型和多线程进程的孩子们认为这就是它一直以来的样子,但我的记忆可以追溯到 Windows 3 的日子,而 Windows 3 没有这些。如果您希望两件事“并行”发生,那就是您所做的:将任务分成小部分并轮流执行部分。整个操作系统就是基于这个概念。

现在,您可能会看到这个类比并说“好吧,但是有些工作,例如烤吐司,是由一台机器完成的”,而 是并行性的来源.当然,我不必雇用工人来烤面包,但我在硬件上实现了并行性。这是正确的思考方式。 硬件并行和线程并行是不同的。当您向网络子系统发出异步请求以从数据库中查找记录时,没有线程 坐在那里等待结果。硬件在远远低于操作系统线程的水平上实现了并行性。

如果您想更详细地了解硬件如何与操作系统一起工作以实现异步,请阅读 Stephen Cleary 的“There is no thread”。

因此,当您看到“异步”时,不要认为“并行”。考虑“将高延迟操作拆分成小块”一个线程。

正如您可能想象的那样,编写控制流非常困难,您可以在其中放弃当前正在做的事情,去做其他事情,然后无缝地从中断的地方继续。这就是我们让编译器完成这项工作的原因! “等待”的意义在于,它允许您通过将这些异步工作流描述为同步工作流来管理它们。在任何地方,你都可以把这个任务放在一边,稍后再回来,写“await”。编译器会负责将您的代码转换成许多可以在异步工作流中调度的小块。

更新:

在你的最后一个例子中,有什么区别


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

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

我假设它同步调用它们但异步执行它们?我不得不承认,我以前从来没有费心单独等待任务。

没有区别。

FryEggAsync 被调用时,无论await 是否出现在它之前,它都会被调用await操作员。它对调用FryEggAsync返回的事物进行操作。它就像任何其他运算符一样。

让我再说一遍:await 是一个运算符,它的操作数是一个任务。可以肯定的是,它是一个非常不寻常的运算符,但从语法上讲,它是一个运算符,并且它像任何其他运算符一样对 value 进行操作。

让我再说一遍: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() 返回一个Task,表示“M 的工作将在未来完成”。 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;

因为这不是所需的工作流程。

因此,您的问题的实际答案是:将 await 推迟到实际需要该值的时间点是一个非常好的做法,因为它增加了有效安排工作的机会。但是你可能走得太远了;确保实现的工作流程是您想要的工作流程。

【讨论】:

  • 我知道await SomeAsyncMethod(); 不会自己神奇地创建另一个线程,但大概 SomeAsyncMethod 在内部使用并行性?我很难理解如何在不使用并行性somewhere 的情况下实现异步编程。比如,在没有DoEggs() 至少在内部同时运行的情况下,你如何告诉程序在等待鸡蛋的同时开始吐司?
  • @Logman:如果我们阻止了延迟步骤,什么会不断从播放器获取更新并刷新 UI?为什么你认为“其他线程的资源”是相关的?不需要有任何其他线程。 “语法解决方案”是什么意思?而且我不确定为什么您认为我的示例说明了不依赖数据的工作流程,而当我考虑不具有数据依赖关系的数据流时,我所想的并不是我所想的。
  • @Logman:你的想法完全错误;我建议你研究一下异步延迟是如何工作的,以及为什么在 void 函数中隐藏 await 不起作用。考虑用这些不同的工作流程实际构建一个小程序,您很快就会明白为什么会做出错误的等价。
  • @EricLippert 这篇文章让我对所有的 Await/Async 都一清二楚 感谢您花时间撰写一篇巨大的综合性帖子!
  • 这就是 StackOverflow 发明的答案。太棒了@EricLippert!
【解决方案2】:

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

我的观点是 async / await 只是一种修饰软件系统的方式,以避免不得不承认,实际上,很多系统(尤其是那些具有大量网络通信的系统)最好被视为 Actor 模型(或者更好的是,通信顺序过程)系统。

对于这两种情况,重点是您等待几件事中的一件可以完成,当一件完成时采取必要的行动,然后返回等待。具体来说,您正在等待消息从其他地方到达,阅读它并根据内容采取行动。在 *nix 中,等待通常通过调用 epoll() 或 select() 来完成。

使用 await / async 只是一种假装您的系统仍然有点同步方法调用(因此很熟悉)的方式,同时难以有效地处理每次都以相同的顺序完成的事情。

但是,一旦您克服了不再调用方法而是简单地来回传递消息的想法,这一切就变得非常自然了。这是一个非常“请这样做”,“当然,这是答案”的事情,许多这样的互动交织在一起。在循环的顶部用一个大的 WaitForLotsOfThings() 调用来结束它只是一个明确的确认,即你的程序将等待直到它有事情要做以响应许多其他与之通信的程序。

Windows 如何让它变得困难

不幸的是,Windows 很难实现前摄器系统(“如果您现在阅读该消息,您就会明白”)。 Windows 是反应堆(“你让我读的那条消息?现在已经读过了。”)。这是一个重要的区别。

对于前者,表示“停止收听其他演员”的消息(或实际上是超时)很容易处理 - 您只需将其他演员从您下次等待时要收听的列表中排除。

有了反应堆,这要困难得多。当读取已经通过某种异步调用开始并且在读取某些内容之前不会完成时,如何尊重“停止收听其他参与者”消息,鉴于最近收到的指令,这是一个值得怀疑的结果?

我在某种程度上吹毛求疵。 Proactor 在具有动态连接的系统中非常有用,Actor 进入系统,再次退出。如果你有固定数量的演员,他们的通讯链接永远不会消失,那么 Reactor 就很好。尽管如此,考虑到反应器系统很容易在前摄器平台上实现,但前摄器系统不容易在反应器平台上实现(时间不会倒退),我发现 Window 的方法特别令人讨厌。

总之,async / await 肯定还在 reactor 中。

敲击影响

这已经感染了许多其他库。

C++ 的 Boost asio 也是反应器,即使在 *nix 上也是如此,主要是因为他们想要一个 Windows 实现。

ZeroMQ 是一个前摄器框架,在某种程度上受限于 Windows,它基于对 select() 的调用(在 Windows 中仅适用于套接字)。

对于 Windows 上的 POSIX 运行时 cygwin 系列,他们必须通过为每个文件描述符设置一个线程来实现 select()、epoll() 等轮询(是的,polling!!!!) 用于传入数据的底层套接字/串行端口/管道,以便重新创建 POSIX 的例程。呸! cygwin 开发者邮件列表中的 cmets 可以追溯到他们实施该部分的时间,这很有趣。

演员不一定慢

值得注意的是,“传递消息”这个短语并不一定意味着传递副本 - 有很多 Actor 模型的公式,您只是传递对消息的引用的所有权(例如,数据流,任务的一部分C# 中的并行库)。这使它很快。我还没有开始查看 Dataflow 库,但它并没有真正使 Windows 前摄器突然之间。它并没有为您提供处理各种数据载体(如套接字、管道、队列等)的 actor 模型前摄器系统。

Windows 10 的 Linux 运行时

所以刚刚爆破了 Windows 和它的低级反应器架构,一个有趣的点是 Windows 10 现在运行 Linux 二进制文件。我非常想知道,微软如何实现 select()、epoll() 的系统调用,因为它必须在 POSIX 领域中的套接字、串行端口、管道和其他所有东西上运行,这是一个文件描述符,当 Windows 上的其他一切都不能时?我想知道这个问题的答案。

【讨论】:

    【解决方案3】:

    通常这是因为一旦异步函数与其他异步函数配合得更好,否则您就会开始失去异步的好处。结果,调用异步函数的函数最终本身就是异步的,并且它遍布整个应用程序,例如。如果您与数据存储异步进行交互,那么使用该功能的事物也往往会异步进行。

    当您将同步代码转换为异步代码时,您会发现,如果异步代码调用并由其他异步代码调用——一直向下(或“向上”,如果您愿意的话)调用,效果最好。其他人也注意到了异步编程的传播行为,并将其称为“传染性”或将其与僵尸病毒相提并论。无论是乌龟还是僵尸,异步代码确实倾向于驱动周围的代码也是异步的。这种行为在所有类型的异步编程中都是固有的,而不仅仅是新的 async/await 关键字。

    来源:Async/Await - Best Practices in Asynchronous Programming

    【讨论】:

      猜你喜欢
      • 1970-01-01
      • 1970-01-01
      • 1970-01-01
      • 1970-01-01
      • 1970-01-01
      • 1970-01-01
      • 2011-01-11
      • 2016-01-30
      • 1970-01-01
      相关资源
      最近更新 更多