118

.net 4.5的异步等待模式正在改变范式。这几乎好得令人难以置信。

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

相当多的人将 async-await 与僵尸感染进行比较,我发现它相当准确。异步代码喜欢其他异步代码(您需要一个异步函数才能等待异步函数)。所以越来越多的函数变得异步,这在你的代码库中不断增长。

将函数更改为异步是有些重复且缺乏想象力的工作。在声明中抛出一个async关键字,将返回值包装起来Task<>,你就完成了。整个过程的简单程度令人不安,很快一个文本替换脚本将为我自动完成大部分“移植”。

现在的问题是.. 如果我所有的代码都慢慢变成异步的,为什么不默认让它全部异步呢?

我假设的明显原因是性能。Async-await 有它的开销和不需要异步的代码,最好不要。但是,如果性能是唯一的问题,那么一些巧妙的优化肯定可以在不需要时自动消除开销。我已经阅读了有关“快速路径”优化的信息,在我看来,它本身应该可以解决大部分问题。

也许这可以与垃圾收集器带来的范式转变相媲美。在早期的 GC 时代,释放自己的内存肯定更有效。但是大众仍然选择自动收集来支持更安全、更简单的代码,这些代码可能效率较低(甚至可以说不再正确)。也许这应该是这里的情况?为什么不应该所有函数都是异步的?

4

4 回答 4

137

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

如果我所有的代码都慢慢变成异步的,为什么不默认让它全部异步呢?

好吧,你在夸大其词;您的所有代码都没有异步。当您将两个“普通”整数相加时,您无需等待结果。当您将两个未来整数加在一起以获得第三个未来整数时——因为这就是Task<int>它,它是一个你将在未来访问的整数——当然你可能会等待结果。

不让一切都异步的主要原因是因为async/await 的目的是使在具有许多高延迟操作的世界中编写代码变得更容易。您的绝大多数操作都没有高延迟,因此降低延迟的性能损失没有任何意义。相反,您的一些关键操作是高延迟,并且这些操作导致整个代码中异步的僵尸感染。

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

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

让我给你三点反对这种转换,然后是优化通过。

第一点是:C#/VB/F# 中的 async 本质上是一种有限的延续传递形式。函数式语言社区中的大量研究已经投入到找出方法来确定如何优化大量使用延续传递样式的代码。在“异步”是默认设置并且必须识别和取消异步化非异步方法的世界中,编译器团队可能必须解决非常相似的问题。C# 团队对承担开放性研究问题并不真正感兴趣,所以这是反对的重点。

反对的第二点是,C# 没有使这些优化更易于处理的“引用透明度”级别。“引用透明度”是指表达式的值不依赖于它何时被评估的属性。像这样的表达式2 + 2在引用上是透明的;如果需要,您可以在编译时进行评估,或者将其推迟到运行时并获得相同的答案。但是像这样的表达式x+y不能及时移动,因为x 和 y 可能会随着时间而变化

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

M();
N();

and M()was void M() { Q(); R(); }, and N()was void N() { S(); T(); }, and RandS产生副作用,那么你知道 R 的副作用发生在 S 的副作用之前。但如果你有async void M() { await Q(); R(); }然后突然消失在窗外。您无法保证R()是在之前还是之后发生S()(当然,除非M()等待;但当然Task不需要等待,直到 after N()。)

现在想象一下,这种不再知道副作用发生顺序的属性适用于程序中的每一段代码,除了那些优化器设法去异步化的代码。基本上,您不再知道哪些表达式将以什么顺序进行计算,这意味着所有表达式都需要是引用透明的,这在像 C# 这样的语言中很难。

反对的第三点是你必须问“为什么异步如此特别?” 如果您要争辩说每个操作实际上都应该是 aTask<T>那么您需要能够回答“为什么不Lazy<T>呢?”这个问题。或“为什么不Nullable<T>呢?” 或“为什么不IEnumerable<T>呢?” 因为我们可以很容易地做到这一点。为什么不应该将每个操作都提升为 nullable?或者每个操作都是惰性计算的,结果被缓存以备后用,或者每个操作的结果是一个值序列,而不仅仅是一个值。然后,您必须尝试优化那些您知道“哦,这绝对不能为空,以便我可以生成更好的代码”的情况,等等。

重点是:我不清楚这Task<T>实际上有什么特别之处来保证这么多的工作。

如果您对这些事情感兴趣,那么我建议您研究像 Haskell 这样的函数式语言,它具有更强的引用透明性并允许各种乱序评估并进行自动缓存。Haskell 在其类型系统中也对我提到的那种“一元提升”有更强大的支持。

于 2013-08-28T23:19:09.330 回答
23

为什么不应该所有函数都是异步的?

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

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

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

此外,有许多例程本质上不是异步的。这些作为同步操作更有意义。例如,强迫Math.SqrtTask<double> Math.SqrtAsync是荒谬的,因为根本没有理由让它是异步的。async您最终会到处await传播,而不是通过您的应用程序进行推送。

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

如果您正在做大量 IO 绑定的工作,您会发现async普遍使用是一个很好的补充,您的许多例程将是async. 然而,当你开始做 CPU 密集型工作时,一般来说,做事情async实际上并不好 - 它隐藏了你在看似异步但实际上不一定真正异步的 API 下使用 CPU 周期的事实。

于 2013-08-28T22:01:42.160 回答
5

抛开性能不谈 - 异步可能会产生生产力成本。在客户端(WinForms、WPF、Windows Phone)上,它是生产力的福音。但是在服务器上,或者在其他非 UI 场景中,你付出了生产力。您当然不想默认在那里异步。当您需要可扩展性优势时使用它。

在甜蜜点时使用它。在其他情况下,不要。

于 2013-08-28T22:07:12.657 回答
4

如果不需要,我相信有充分的理由使所有方法异步 - 可扩展性。选择性使方法异步仅在您的代码永远不会发展并且您知道方法 A() 始终受 CPU 限制(保持同步)并且方法 B() 始终受 I/O 限制(将其标记为异步)时才有效。

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

如果可以在不更改 API 的情况下使现有的同步方法异步,那就太好了。但在现实中我们没有这样的选择,我相信,即使当前不需要使用异步版本也是保证您将来永远不会遇到兼容性问题的唯一方法。

于 2015-03-02T13:51:24.057 回答