首先,感谢您的客气话。这确实是一个很棒的功能,我很高兴成为其中的一小部分。
如果我所有的代码都慢慢变成异步的,为什么不默认让它全部异步呢?
好吧,你在夸大其词;您的所有代码都没有异步。当您将两个“普通”整数相加时,您无需等待结果。当您将两个未来整数加在一起以获得第三个未来整数时——因为这就是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 R
andS
产生副作用,那么你知道 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 在其类型系统中也对我提到的那种“一元提升”有更强大的支持。