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

时间:2013-08-28 21:59:59

标签: c# .net asynchronous async-await

.net 4.5的async-await模式正在改变范式。这真是太好了。

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

相当多的人正在比较async-await和僵尸感染,我发现它相当准确。异步代码喜欢其他异步代码(您需要异步函数才能等待异步函数)。因此,越来越多的函数变得异步,并且在代码库中不断增长。

将函数更改为异步是一种重复性和缺乏想象力的工作。在声明中抛出一个async关键字,将返回值包装为Task<>,你已经完成了很多工作。令人不安的是整个过程是多么容易,而且很快文本替换脚本将为我自动化大部分“移植”。

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

我假设的显而易见的原因是表现。 Async-await有它的开销和代码,不需要异步,最好不要。但是,如果性能是唯一的问题,那么一些聪明的优化肯定可以在不需要时自动消除开销。我已经阅读了关于"fast path"优化的内容,在我看来,它应该只关注它的大部分内容。

也许这与垃圾收集者带来的范式转变相当。在GC早期,释放自己的记忆肯定更有效率。但群众仍然选择自动收集,转而采用更安全,更简单的代码,这些代码可能效率较低(甚至可能不再适用)。也许这应该是这样的情况?为什么不能将所有函数都异步?

4 个答案:

答案 0 :(得分:110)

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

  

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

嗯,你夸张了; 全部您的代码没有变为异步。当您将两个“普通”整数添加到一起时,您不会等待结果。当您将两个 future future 一起添加到第三个 future integer 时 - 因为那是Task<int>,它是一个你将获得访问权限的整数在将来 - 当然你可能会等待结果。

不使所有内容异步的主要原因是 async / await的目的是使在具有许多高延迟操作的世界中编写代码更容易。绝大多数操作都是而不是高延迟,因此采用可以减轻延迟的性能损失没有任何意义。相反,少数几个操作是高延迟,而这些操作导致整个代码中的异步僵尸感染。

  

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

理论上,理论和实践是相似的。在实践中,他们永远不会。

让我给出三点反对这种转换,接着是优化传递。

第一点是:C#/ VB / F#中的异步基本上是继续传递的有限形式。函数式语言社区的大量研究已经开始找出如何优化代码的方法,这些代码大量使用了延续传递方式。编译器团队可能必须解决非常类似的问题,在这个问题中,“async”是默认的,并且必须识别非异步方法并解除异步。 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() { {1}}。)

现在假设的这个属性不再知道中发生了什么顺序副作用,适用于程序中的每一段代码,除了优化程序设法的那些异步IFY。基本上你不再知道哪些表达式将按什么顺序进行评估,这意味着所有表达式都必须是引用透明的,这在C#这样的语言中很难。

第三点反对的是你必须问“为什么异步如此特别?”如果您要争辩说每个操作实际上应该是Task<T>,那么您需要能够回答“为什么不Lazy<T>?”的问题。或“为什么不Nullable<T>?”或“为什么不IEnumerable<T>?”因为我们可以轻松地做到这一点。为什么不应该将每个操作都提升为可以为空的?或每个操作都是懒惰计算的,结果会缓存以供日后使用,或每个操作的结果都是一系列值而不是一个值。然后,您必须尝试优化那些您知道“哦,这绝不能为空,因此我可以生成更好的代码”的情况,等等。 (事实上​​,C#编译器确实可以用于提升算术。)

重点是:我不清楚Task<T>实际上是否特别需要这么多工作。

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

答案 1 :(得分:21)

  

为什么不能将所有函数都异步?

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

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

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

此外,有许多例程本质上不是异步的。那些作为同步操作更有意义。例如,强制Math.SqrtTask<double> Math.SqrtAsync将是荒谬的,因为没有任何理由可以异步。您最终会async传播到处,而不是await推送您的应用程序。

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

如果你正在进行大量的IO绑定工作,你会发现普遍使用async是一个很好的补充,你的许多例程将是async。但是,当你开始进行CPU绑定工作时,一般情况下,使事情async实际上并不好 - 它隐藏了你在出现的API下使用CPU周期的事实异步,但实际上不一定是真正的异步。

答案 2 :(得分:3)

性能除外 - 异步可能会降低生产力成本。在客户端(WinForms,WPF,Windows Phone)上,它可以提高工作效率。但是在服务器或其他非UI场景中,您支付生产力。你肯定不希望在那里默认异步。在需要可扩展性优势时使用它。

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

答案 3 :(得分:2)

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

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

如果可以在不更改API的情况下使现有的同步方法异步,那将会很棒。但实际上我们没有这样的选择,我相信,并且使用异步版本,即使它目前还不需要,也是保证你未来不会遇到可比性问题的唯一方法。