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

Posted

技术标签:

【中文标题】为啥默认情况下所有函数都不应该是异步的?【英文标题】:Why shouldn't all functions be async by default?为什么默认情况下所有函数都不应该是异步的? 【发布时间】:2013-09-01 04:16:52 【问题描述】:

.net 4.5 的async-await 模式正在改变范式。好得令人难以置信。

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

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

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

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

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

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

【问题讨论】:

感谢 C# 团队标记地图。就像几百年前做的那样,“龙卧于此”。你可以装备一艘船去那里,很可能你会在阳光普照和风在你背后的情况下幸存下来。有时没有,他们再也没有回来。就像 async/await 一样,SO 充满了来自不了解他们是如何脱离地图的用户的问题。尽管他们得到了很好的警告。现在是他们的问题,而不是 C# 团队的问题。他们标记了龙。 @Sayse 即使您消除了同步和异步函数之间的区别,同步实现的调用函数仍然是同步的(例如您的 WriteLine 示例) “按任务包装返回值” 除非您的方法必须是async(以履行某些合同),否则这可能是个坏主意。你得到了异步的缺点(方法调用的成本增加;在调用代码中必须使用await),但没有任何优点。 这是一个非常有趣的问题,但可能不太适合 SO。我们可能会考虑将其迁移到 Programmers。 也许我遗漏了一些东西,但我有完全相同的问题。如果 async/await 总是成对出现并且代码仍然必须等待它完成执行,那么为什么不只是更新现有的 .NET 框架,并让那些需要异步的方法 - 默认情况下是异步的,而不需要额外的关键字呢?该语言已经变成了它旨在逃避的东西 - 关键字意大利面条。我认为在建议使用“var”之后他们应该停止这样做。现在我们有了“动态”、异步/等待......等等......为什么你们不只是.NET-ify javascript? ;)) 【参考方案1】:

首先,感谢您的客气话。这确实是一个很棒的功能,我很高兴能参与其中。

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

好吧,你夸大其词了; 所有您的代码没有变成异步的。当您将两个“普通”整数相加时,您无需等待结果。当你将两个未来整数相加得到第三个未来整数——因为这就是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# 这样的语言中很难。

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

重点是:我不清楚Task<T> 是否真的那么特别,值得做这么多工作。

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

【讨论】:

在没有等待的情况下调用异步函数对我来说没有意义(在常见情况下)。如果我们要删除这个特性,编译器可以自行决定一个函数是否是异步的(它是否调用 await?)。然后我们可以对这两种情况(异步和同步)使用相同的语法,并且只在调用中使用 await 作为区分。僵尸感染解决了:) 根据您的要求,我已在程序员中继续讨论:programmers.stackexchange.com/questions/209872/… @EricLippert - 一如既往的非常好的答案:) 我很好奇你能否澄清“高延迟”?这里有以毫秒为单位的一般范围吗?我只是想弄清楚使用异步的下界线在哪里,因为我不想滥用它。 @TravisJ:指导是:不要阻塞 UI 线程超过 30 毫秒。不仅如此,您还会冒着暂停被用户注意到的风险。 挑战我的是,同步或异步完成某事是一个可以改变的实现细节。但是该实现中的更改会通过调用它的代码、调用它的代码等等产生连锁反应。我们最终会因为它所依赖的代码而更改代码,这是我们通常会竭尽全力避免的事情。或者我们使用async/await,因为隐藏在抽象层之下的东西可能是异步的。【参考方案2】:

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

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

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

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

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

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

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

【讨论】:

正是我要写的(性能),向后兼容性可能是另一回事,dll 也可以与不支持异步/等待的旧语言一起使用 如果我们简单地消除同步和异步函数之间的区别,让 sqrt 异步并不可笑 @talkol 我想我会扭转这种局面 - 为什么应该每个函数调用都具有异步的复杂性? @talkol 我认为这不一定是正确的 - 异步可以添加比阻塞更糟糕的错误...... @talkol await FooAsync()Foo() 简单吗?有时不是小多米诺骨牌效应,而是一直都有巨大的多米诺骨牌效应,你称之为改进吗?【参考方案3】:

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

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

【讨论】:

+1 - 简单地尝试让代码运行 5 个异步操作和随机完成顺序的思维导图对于大多数人来说已经足够痛苦一天了。推理异步(因此本质上是并行)代码的行为比好的旧同步代码要困难得多......【参考方案4】:

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

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

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

【讨论】:

虽然这似乎是一个极端的评论,但其中有很多道理。首先,由于这个问题:“这会破坏 API 和堆栈中的所有调用者” async/await 使应用程序更紧密地耦合。例如,如果子类想要使用 async/await,它很容易破坏 Liskov Substitution 原则。此外,很难想象大多数方法不需要 async/await 的微服务架构。

以上是关于为啥默认情况下所有函数都不应该是异步的?的主要内容,如果未能解决你的问题,请参考以下文章

hdfs 中的权限组(默认情况下)如何工作?为啥所有用户文件都属于超级组?

为啥spring boot可以在没有默认构造函数的情况下反序列化类?

golang函数中的参数为啥不支持默认值

默认情况下为啥没有设置线程化 perl(带有 usethreads)?

默认情况下优先级应该是谁先注册谁的优先级就高吧?

此异步等待不应该工作。为啥它会起作用?