为什么默认情况下所有功能都不应该异步?


107

.net 4.5 的异步等待模式正在改变范式。真是太好了。

我一直在将一些IO繁重的代码移植到async-await中,因为阻塞已成为过去。

不少人将异步等待与僵尸侵扰进行了比较,我发现它相当准确。异步代码与其他异步代码一样(您需要一个异步函数才能等待一个异步函数)。因此,越来越多的功能变得异步,并且这在您的代码库中不断增长。

将功能更改为异步在某种程度上是重复的并且是没有想象力的工作。async在声明中添加一个关键字,将返回值包装为Task<>,您已经完成了很多工作。整个过程有多么容易,这让人很不安,很快,一个替换文本的脚本将为我自动完成大多数“移植”。

现在是问题了。如果我的所有代码都在缓慢地变为异步状态,为什么不将其全部默认情况下设为异步呢?

我认为明显的原因是性能。异步等待有它的开销,不需要异步的代码,最好不要。但是,如果性能是唯一的问题,那么肯定可以进行一些巧妙的优化,从而在不需要时自动消除开销。我已经阅读了有关“快速路径”优化的信息,在我看来,仅它一项就可以解决大部分问题。

也许这可以与垃圾收集器带来的范式转变相提并论。在早期的GC时代,释放自己的内存肯定更有效。但是,大众仍然选择自动收集,而选择效率更高,更简单的代码,这些代码可能效率较低(甚至可以说不再适用)。也许这里应该是这样吗?为什么所有功能都不应该异步?


8
感谢C#团队标记地图。就像几百年前一样,“龙躺在这里”。您可以装备一艘船去那里,很可能您会在阳光照耀和背风的作用下生存下来。有时不是,他们再也没有回来。就像async / await一样,SO充满了来自不了解他们如何离开地图的用户的问题。即使他们得到了很好的警告。现在是他们的问题,而不是C#团队的问题。他们标记了龙。
汉斯·帕桑

1
@Sayse即使您删除了同步和异步功能之间的区别,调用同步执行的功能仍将是同步的(例如您的WriteLine示例)
talkol 2013年

2
“用Task <>包装返回值”除非您的方法必须是async(履行某些合同),否则这可能不是一个好主意。您将获得异步的缺点(方法调用的成本增加;await在调用代码中使用的必要性),但没有优点。
svick

1
这是一个非常有趣的问题,但可能不太适合SO。我们可能会考虑将其迁移到程序员。
埃里克·利珀特

1
也许我错过了一些东西,但是我有完全相同的问题。如果async / await总是成对出现,并且代码仍然必须等待它完成执行,那么为什么不只更新现有的.NET框架,并使那些方法需要异步-默认情况下异步而不用额外的关键字呢?这种语言已经变成了设计要逃脱的语言-关键字意粉。我认为在建议使用“ var”之后,他们应该停止这样做。现在我们有了“动态的”,asyn / await ...等...为什么你们不只是.NET-ify javascript?;))
monstro 2014年

Answers:


127

首先,谢谢您的客气话。这确实是一个了不起的功能,我很高兴成为其中的一小部分。

如果我所有的代码都在缓慢地变为异步状态,为什么不将其默认设置为异步呢?

好吧,你太夸张了;您所有的代码都不会变得异步。当您将两个“普通”整数相加时,就不必等待结果了。当您将两个将来的整数加在一起以得到第三个将来的整数时(因为那样的话Task<int>,这是将来您将要访问的整数)当然您可能会等待结果。

不使所有内容都异步的主要原因是因为async / await的目的是使在具有许多高延迟操作的世界中更容易编写代码。绝大多数的操作都是高的延迟,所以它没有任何意义,采取业绩击中其减轻该延迟。相反,您的关键操作中有几个是高延迟,而这些操作导致整个代码中僵死的异步侵扰。

如果性能是唯一的问题,那么肯定有一些聪明的优化可以在不需要时自动消除开销。

在理论上,理论和实践是相似的。实际上,它们从来都不是。

让我针对这种转换以及优化遍历给出三点意见。

同样,第一点是:C#/ VB / F#中的异步本质上是延续传递的一种有限形式。在功能语言社区中,大量研究已经找到了确定如何优化代码的方法,这些代码大量使用了延续传递样式。在默认情况下,“异步”是默认设置,必须识别和取消异步方法,在这种情况下,编译器团队可能必须解决非常相似的问题。C#团队对解决开放式研究问题并不十分感兴趣,因此这很重要。

反对的第二点是,C#没有“参照透明”级别,这使得这类优化更加容易处理。“参照透明性”是指表达式的值不依赖于何时对其求值的属性。类似2 + 2的表达式是参照透明的;您可以根据需要在编译时进行评估,也可以将其推迟到运行时并得到相同的答案。但是像这样的表达式x+y不能及时移动,因为x和y可能随着时间改变

异步使推理何时发生副作用变得更加困难。在异步之前,如果您说:

M();
N();

M()void M() { Q(); R(); }N()曾经void N() { S(); T(); }RS产生副作用,那么您知道R的副作用先于S的副作用发生。但是,如果您async void M() { await Q(); R(); }突然遇到了,那便是窗外。您无法保证R()会在发生之前还是之后发生S()(当然,除非M()等待;但是当然Task不必等到之后)N()

现在想象一下,这种不再知道发生什么顺序副作用的属性适用于程序中的每段代码,除了优化程序设法取消异步处理的那些代码。基本上,您再也不知道哪个表达式将按什么顺序求值了,这意味着所有表达式都必须是参照透明的,这在像C#这样的语言中很难做到。

第三点是,您必须问“为什么异步如此特别?” 如果您要争论每个操作实际上应该是a,Task<T>那么您需要能够回答“为什么不Lazy<T>?” 这个问题。或“为什么不Nullable<T>呢?” 或“为什么不IEnumerable<T>呢?” 因为我们可以轻松地做到这一点。为什么不应该将每个操作都提升为可空值呢?或者,每个操作都是延迟计算的,结果将被缓存以备后用,或者每个操作的结果都是一个值序列,而不仅仅是一个值。然后,您必须尝试优化那些您知道“噢,这绝不能为null,这样我才能生成更好的代码”的情况,依此类推。

要点是:我不清楚Task<T>实际上需要进行这么多工作的特殊之处。

如果您对这些事情感兴趣,那么我建议您研究诸如Haskell之类的功能语言,它们具有更强的引用透明度,并允许进行各种乱序评估并进行自动缓存。Haskell在其类型系统中也为我提到的“单子提升”提供了更强大的支持。


在我看来,没有等待而调用异步函数是没有意义的(在通常情况下)。如果要删除此功能,则编译器可以自行确定函数是否异步(它调用await吗?)。这样我们就可以在两种情况下(异步和同步)使用相同的语法,并且仅在调用中使用await作为区分符。僵尸出没已解决:)
talkol

我继续按照您的要求程序员讨论:programmers.stackexchange.com/questions/209872/...
talkol

@EricLippert-一如既往的很好:)我很好奇您是否可以澄清“高延迟”?这里有毫秒的一般范围吗?我只是想弄清楚使用异步的下边界线在哪里,因为我不想滥用它。
特拉维斯J

7
@TravisJ:指导是:不要将UI线程阻塞超过30毫秒。除此之外,您还存在用户注意到暂停的风险。
埃里克·利珀特

1
我面临的挑战是,无论同步还是异步完成某项操作都是可以更改的实现细节。但是实现的更改会在调用它的代码,调用它的代码等之间产生连锁反应。由于代码所依赖的原因,我们最终会更改代码,通常我们会尽力避免这种情况。或者我们async/await之所以使用,是因为隐藏在抽象层下的某些内容可能是异步的。
Scott Hannen

23

为什么所有功能都不应该异步?

正如您提到的,性能是原因之一。请注意,在完成Task的情况下,链接到的“快速路径”选项确实可以提高性能,但是与单个方法调用相比,它仍然需要更多的指令和开销。这样,即使有了“快速路径”,每次异步方法调用也会增加很多复杂性和开销。

向后兼容性以及与其他语言(包括互操作方案)的兼容性也将成为问题。

另一个问题是复杂性和意图。异步操作增加了复杂性-在许多情况下,语言功能掩盖了这一点,但是在许多情况下,制作方法async无疑会增加其使用的复杂性。如果没有同步上下文,则尤其如此,因为异步方法很容易最终导致意外的线程问题。

另外,从本质上讲,有许多例程不是异步的。这些作为同步操作更有意义。强制Math.SqrtTask<double> Math.SqrtAsync是可笑的,例如,因为没有道理可言的,要成为异步的。相反,具有async通过应用程序推送,你会最终awaitpropogating 随处可见

这也将完全打破当前的范式,并导致属性问题(实际上只是方法对……它们也会异步吗?),并在整个框架和语言设计中产生其他影响。

如果您要进行大量的IO绑定工作,您会发现async普遍使用是一个很好的补充,许多例程将是async。但是,当您开始进行CPU限制的工作时,通常做起来async实际上并不好-这掩盖了您使用的API 似乎是异步的,但实际上并不一定是真正异步的,正在使用CPU周期。


正是我要写的(性能),向后兼容性可能是另一回事,dll也将与不支持异步/等待的较旧语言一起使用
Sayse 2013年

如果我们简单地删除同步和异步功能之间的区别,使sqrt异步并不可笑
talkol

@talkol我想我会扭转这个局面-为什么每个函数调用的作为在异步的复杂性?
Reed Copsey 2013年

2
@talkol我认为这不一定是正确的-异步本身会添加比阻止更严重的错误……
Reed Copsey 2013年

1
@talkol await FooAsync()Foo()哪个更简单?而不是有时候会产生小的多米诺骨牌效应,而是一直都具有巨大的多米诺骨牌效应,您称之为改进吗?
svick

4

除了性能-异步可能会产生生产力成本。在客户端(WinForms,WPF,Windows Phone)上,这是提高生产力的福音。但是,在服务器上或在其他非UI方案中,您要付出生产力。您当然不想在默认情况下进入异步状态。需要扩展性优势时使用它。

在最佳位置使用它。在其他情况下,请不要这样做。


2
+1-简单地尝试让代码的思维导图同时并行执行5个异步操作和随机的完成顺序,这将为大多数人一天提供足够的痛苦。关于异步(因此本质上是并行)代码的行为的推理比好的旧同步代码要难得多……
Alexei Levenkov

2

我相信,如果不需要扩展所有方法,则有充分的理由使所有方法异步。仅当您的代码永不进化并且您知道方法A()始终受CPU约束(保持同步)并且方法B()始终受I / O约束(将其标记为异步)时,异步选择生成方法才有效。

但是,如果事情改变了怎么办?是的,A()正在执行计算,但是在将来的某个时候,您必须在其中添加日志记录或报告,或者使用无法预测的实现来定义用户定义的回调,或者算法已经扩展,现在不仅包括CPU计算,还包括还有一些I / O吗?您需要将方法转换为异步,但这会破坏API,并且堆栈中的所有调用方也需要进行更新(它们甚至可以是来自不同供应商的不同应用程序)。或者,您需要在同步版本的旁边添加异步版本,但这并没有太大的区别-使用同步版本会阻止,因此很难接受。

如果可以在不更改API的情况下使现有的同步方法异步,那就太好了。但我认为,实际上,我们没有这种选择,并且即使当前不需要异步版本,使用异步版本也是确保您以后再也不会遇到兼容性问题的唯一方法。


尽管这似乎是一种极端的评论,但其中包含许多事实。首先,由于这个问题:“这将破坏API并使所有调用者都陷入困境” async / await使应用程序更加紧密地耦合在一起。例如,如果子类想要使用async / await,则很容易违反Liskov替换原则。而且,很难想象微服务架构中的大多数方法不需要异步/等待。
ItsAllABadJoke
By using our site, you acknowledge that you have read and understand our Cookie Policy and Privacy Policy.
Licensed under cc by-sa 3.0 with attribution required.