为什么默认情况下不应该所有函数都是异步的?



.net 4.5的异步等待模式正在改变模式。这几乎太好了,不可能是真的。

我一直在将一些IO繁重的代码移植到异步等待中,因为阻塞已经成为过去。

相当多的人将异步等待比作僵尸侵扰,我发现它相当准确。异步代码类似于其他异步代码(您需要一个异步函数来等待异步函数)。因此,越来越多的函数变得异步,这在代码库中不断增长。

将函数更改为异步是一项有些重复和缺乏想象力的工作。在声明中抛出一个async关键字,用Task<>包装返回值,就差不多完成了。整个过程是多么容易,这让人相当不安,很快一个文本替换脚本将自动为我进行大部分"移植"

现在的问题。。如果我所有的代码都在慢慢地变成异步,为什么不在默认情况下让它都变成异步呢?

我认为最明显的原因是性能。异步等待有其开销,并且代码不需要异步,最好不应该。但是,如果性能是唯一的问题,那么一些巧妙的优化肯定可以在不需要时自动消除开销。我读过关于"快速路径"优化的文章,在我看来,它应该单独解决大部分问题

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

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

如果我所有的代码都在慢慢地变成异步,为什么不在默认情况下让它都变成异步呢?

好吧,你太夸张了all您的代码没有变为异步。当你把两个"普通"整数加在一起时,你并不是在等待结果。当你把两个未来整数加在一起得到第三个future整数--因为这就是Task<int>,它是一个你将来可以访问的整数--当然你可能正在等待结果。

不使所有内容都异步的主要原因是async/await的目的是在有许多高延迟操作的世界中更容易编写代码。您的绝大多数操作都是而非高延迟,因此降低延迟的性能打击毫无意义。相反,关键的几个操作是高延迟的,这些操作导致整个代码中异步的僵尸侵扰。

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

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

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

第一点是:C#/VB/F#中的异步本质上是延续传递的有限形式。函数语言社区中有大量的研究致力于找出如何优化大量使用延续传递风格的代码的方法。编译器团队可能必须解决非常类似的问题,在这个世界里,"异步"是默认的,非异步方法必须被识别和去异步化。C#团队对开放研究问题并不感兴趣,所以这是一个很大的反对点。

反对的第二点是,C#没有"引用透明度"级别,这使得这些优化更容易处理。我所说的"引用透明度"是指表达式的值不依赖于何时求值的属性。像2 + 2这样的表达式是参照透明的;如果需要,可以在编译时进行评估,也可以将评估推迟到运行时,得到相同的答案。但是像x+y这样的表达式不能随时间移动,因为x和y可能会随时间变化

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

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#这样的语言中很难做到。

反对的第三点是,你必须问"为什么异步如此特殊?"如果你要争辩说每个操作实际上都应该是Task<T>,那么你需要能够回答"为什么不Lazy<T>?"或"为什么不Nullable<T>?"或"为什么不IEnumerable<T>?"因为我们可以很容易地做到这一点。为什么不应该是每个操作都提升为可为null的?或者每个操作都是延迟计算的,并且缓存结果以备以后使用,或者每个操作的结果都是一系列值,而不是一个值。然后,你必须尝试优化那些你知道"哦,这绝不能是null,这样我就可以生成更好的代码"的情况,等等

重点是:我不清楚Task<T>是否真的那么特别,足以保证这么多工作。

如果你对这类事情感兴趣,那么我建议你研究像Haskell这样的函数式语言,它们具有更强的引用透明度,允许各种无序的评估和自动缓存。Haskell在其类型系统中也对我提到的"一元提升"有更强的支持

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

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

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

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

此外,还有许多例程本质上不是异步的。这些作为同步操作更有意义。例如,将Math.Sqrt强制为Task<double> Math.SqrtAsync将是荒谬的,因为根本没有理由将其异步化。与其让async推送您的应用程序,不如让await到处推广

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

如果你正在做很多与IO相关的工作,你会发现广泛使用async是一个很好的补充,你的许多例程都是async。然而,当你开始做与CPU绑定的工作时,通常情况下,制作async实际上并不好——它掩盖了这样一个事实,即你在API下使用CPU周期,看起来是异步的,但实际上不一定真的是异步的。

抛开性能不谈——异步可能会带来生产成本。在客户端(WinForms、WPF、Windows Phone)上,这对生产力是一个福音。但在服务器上,或在其他非UI场景中,您支付生产力。在默认情况下,您当然不希望异步。当您需要可扩展性优势时使用它。

在最佳时机使用。在其他情况下,不要。

如果不需要扩展性,我相信有充分的理由使所有方法异步。只有当你的代码从未进化,并且你知道方法A()总是CPU绑定的(你保持它同步)和方法B()总是I/O绑定的(把它标记为async)时,选择性地使方法异步才有效。

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

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

最新更新