C#中异步/等待用法的准则是否与好的架构和抽象分层的概念相矛盾?


103

这个问题与C#语言有关,但我希望它涵盖其他语言,例如Java或TypeScript。

Microsoft建议在.NET中使用异步调用的最佳做法。在这些建议中,让我们选择两个:

  • 更改异步方法的签名,以便它们返回Task或Task <>(在TypeScript中,这将是Promise <>)
  • 更改异步方法的名称以xxxAsync()结尾

现在,当用异步组件替换低级同步组件时,这会影响应用程序的整个堆栈。由于异步/等待仅在“一直使用”时才有积极影响,因此这意味着必须更改应用程序中每一层的签名和方法名称。

好的体系结构通常涉及在每个层之间放置抽象,这样高层组件就看不到用其他组件替换低层组件。在C#中,抽象采用接口的形式。如果我们引入了一个新的,低级的异步组件,则调用堆栈中的每个接口都需要修改或替换为一个新接口。在实现类中解决问题(异步或同步)的方式不再对调用方隐藏(抽象)。调用者必须知道它是同步还是异步。

异步/等待与“良好架构”原则相抵触的最佳做法吗?

这是否意味着每个接口(例如IEnumerable,IDataAccessLayer)都需要它们的异步对应项(IAsyncEnumerable,IAsyncDataAccessLayer),以便在切换到异步依赖项时可以在堆栈中对其进行替换?

如果我们进一步推论这个问题,那么假设每个方法都是异步的(返回Task <>或Promise <>),并且使方法在实际上不是实际的情况下同步,会不会更简单?异步?这是将来的编程语言所期望的吗?


5
尽管这听起来像是一个很棒的讨论问题,但我认为这是基于观点的,因此无法在此处回答。
欣快的

22
@Euphoric:我认为这里解决的问题比C#指南要深,这只是一个事实的征兆,即将应用程序的某些部分更改为异步行为可能会对整个系统产生非本地影响。因此,我的直觉告诉我,基于技术事实,对此必须有一个没有质疑的答案。因此,我鼓励在座的每个人不要过早地结束这个问题,而要让我们等待将会给出什么样的答案(如果他们太自以为是,我们仍然可以投票赞成结束)。
布朗

25
@DocBrown我认为这里更深层的问题是“是否可以将系统的一部分从同步更改为异步,而不必依赖于系统的部分也进行更改?” 我认为答案是明确的“不”。在那种情况下,我看不到“好的体系结构和分层概念”在这里如何应用。
欣快的

6
@Euphoric:听起来像是一个没有问题的答案的好基础;-)
布朗

5
@Gherman:因为C#和许多语言一样,不能仅基于返回类型进行重载。您最终将获得与同步对象具有相同签名的异步方法(并非所有方法都可以使用CancellationToken,并且确实希望提供默认值的方法)。显然,删除现有的同步方法(并主动破坏所有代码)是一个不起眼的问题。
Jeroen Mostert

Answers:


111

您的功能是什么颜色?

您可能对Bob Nystrom的《功能是什么颜色》1感兴趣。

在本文中,他描述了一种虚构的语言,其中:

  • 每个功能都有一种颜色:蓝色或红色。
  • 红色功能可以调用蓝色或红色功能,没有问题。
  • 蓝色功能只能调用蓝色功能。

尽管是虚构的,但在编程语言中却经常发生这种情况:

  • 在C ++中,“ const”方法只能在上调用其他“ const”方法this
  • 在Haskell中,非IO功能只能调用非IO功能。
  • 在C#中,同步功能只能调用同步功能2

正如您已经意识到的那样,由于这些规则,红色函数倾向于在代码库中散布。您插入一个,然后一点一点地殖民整个代码库。

1 除博客外,鲍勃·尼斯特罗姆(Bob Nystrom)还是Dart团队的一员,并撰写了这个小手工艺品译名系列;强烈推荐用于任何编程语言/编译器爱好者。

2 不太正确,因为您可能调用异步函数并阻塞直到返回,但是...

语言限制

本质上,这是语言/运行时限制。

例如,具有M:N线程的语言(例如Erlang和Go)没有async功能:每个功能都可能是异步的,并且其“纤维”将仅被挂起,换出并在再次准备好时换回。

C#采用1:1线程模型,因此决定在语言中实现表面同步,以避免意外阻塞线程。

在存在语言限制的情况下,必须适应编码准则。


4
IO函数确实有扩展的趋势,但是通过勤奋工作,您几乎可以将它们隔离到靠近代码入口点(在调用时在堆栈中)的函数。您可以通过让那些函数调用IO函数,然后让其他函数处理它们的输出并返回进一步IO所需的任何结果,来实现此目的。我发现这种风格使我的代码库更易于管理和使用。我想知道是否存在同步性的必然结果。
jpmc26

16
“ M:N”和“ 1:1”线程是什么意思?
曼队长

14
@CaptainMan:1:1线程表示将一个应用程序线程映射到一个OS线程,在C,C ++,Java或C#等语言中就是这种情况。相比之下,M:N线程意味着将M个应用程序线程映射到N个OS线程;在Go语言中,应用程序线程称为“ goroutine”,在Erlang语言中,称为“ actor”,您可能还听说过它们是“绿色线程”或“纤维”。它们无需并行即可提供并发性。不幸的是,有关该主题Wikipedia文章很少。
Matthieu M.

2
这有点相关,但我也认为这种“功能颜色”的想法也适用于阻止用户输入的功能,例如模式对话框,消息框,某些形式的控制台I / O等,这些功能是框架从一开始就具有。
jrh

2
@MatthieuM。C#的每个操作系统线程没有一个应用程序线程,也从未如此。当您与本机代码进行交互时,这非常明显,尤其是在MS SQL中运行时。当然,合作例程总是可行的(并且使用甚至更简单async);实际上,这是构建响应式UI的一种非常常见的模式。和Erlang一样漂亮吗?不。但这与C仍然相去甚远:)
a安

82

没错,这里有矛盾,但这并不是“最佳实践”不好。这是因为异步功能所做的本质上不同于同步功能。它无需等待其依赖项(通常是某些IO)的结果,而是创建了一个由主事件循环处理的任务。这不是可以很好地隐藏在抽象之下的差异。


27
答案就像这个IMO一样简单。同步和异步过程之间的区别不是实现细节,而是语义上不同的协定。
Ant P

11
@AntP:我不同意这么简单。它以C#语言显示,但不以Go语言显示。因此,这不是异步过程的固有属性,而是如何在给定语言中对异步过程进行建模的问题。
Matthieu M.

1
@MatthieuM。是的,但是您也可以使用asyncC#中的方法来提供同步合同。唯一的区别是Go默认情况下是异步的,而C#默认情况下是同步的。async给您第二种编程模型- async 抽象(它的实际作用取决于运行时,任务计划程序,同步上下文,等待程序的实现...)。
a安18/12/9

6

我敢肯定,异步方法的行为与同步方法的行为有所不同。在运行时,将异步调用转换为同步调用是微不足道的,但是不能说相反的说法。因此,逻辑就变成了,为什么我们不对每个可能需要它的方法都使用异步方法,而让调用者根据需要“转换”为同步方法呢?

从某种意义上讲,这就像有一个抛出异常的方法,另一个是“安全的”并且即使发生错误也不会抛出异常的方法。编码器在什么时候过度提供这些方法,否则这些方法可以相互转换?

在这种情况下,有两种思路:一种是创建多个方法,每个方法都调用另一种可能的私有方法,从而允许为行为提供可选参数或较小的更改,例如异步。另一方法是将接口方法最小化,使其仅包含基本要素,而由调用者自己进行必要的修改。

如果您是第一所学校,则有一定的逻辑可将一类用于同步和异步调用,以避免每个调用加倍。Microsoft倾向于采用这种思想,并且按照惯例,要与Microsoft所支持的样式保持一致,您也必须拥有Async版本,其方式几乎与接口始终以“ I”开头的方式相同。让我强调一下,从本质上说,这并没有,因为在项目中保持一致的样式比以“正确的方式”做事并从根本上更改添加到项目的开发的样式要好。

就是说,我倾向于第二所学校,即尽量减少接口方法。如果我认为某个方法可以异步方式调用,那么对我来说该方法是异步的。呼叫者可以决定在继续之前是否等待该任务完成。如果此接口是库的接口,则采用这种方法更合理,以最大程度减少您不赞成使用或调整的方法的数量。如果该接口供我的项目内部使用,我将为整个项目中的每个所需调用添加一个方法,以提供的参数,并且不添加任何“额外”方法,即使这样,前提是该方法的行为尚未涵盖通过现有方法。

但是,像该领域的许多事情一样,它在很大程度上是主观的。两种方法各有利弊。Microsoft还开始约定在变量名称的开头添加表示类型的字母,并使用“ m_”表示其是成员,从而导致变量名称(如)m_pUser。我的观点是,即使Microsoft也不是绝对可靠的,并且也会出错。

也就是说,如果您的项目遵循此异步约定,则建议您尊重它并继续使用该样式。而且只有给了自己的项目后,您才能以自己认为合适的最佳方式编写它。


6
“在运行时,将异步调用转换为同步调用是微不足道的”,我不确定是否确实如此。在.NET中,使用.Wait()method之类的方法可能会导致负面后果,而据我所知,在js中根本不可能。
max630

2
@ max630我没有说没有并发问题要考虑,但是如果它最初是一个同步任务,很可能不会造成死锁。也就是说,琐碎并不意味着“双击此处即可转换为同步”。在js中,您返回一个Promise实例,并在其上调用resolve。
尼尔

2
是的,将异步转换回同步完全是一件痛苦的事
Ewan

4
@Neil在javascript中,即使您调用Promise.resolve(x),然后向其添加回调,这些回调也不会立即执行。
NickL

1
@Neil如果接口公开了一个异步方法,则期望等待Task不会产生死锁不是一个好假设。接口显示它实际上在方法签名中是同步的,这比文档中可能在以后版本中更改的承诺要好得多。
卡尔·沃尔什

2

假设有一种方法可以使您以异步方式调用函数而无需更改其签名。

那真的很酷,没有人会建议您更改他们的名字。

但是,实际的异步功能,不仅是等待另一个异步功能的异步功能,而且最低层的功能也具有特定于其异步特性的结构。例如

public class HTTPClient
{
    public HTTPResponse GET()
    {
        //send data
        while(!timedOut)
        {
            //check for response
            if(response) { 
                this.GotResponse(response); 
            }
            this.YouCanWait();
        }
    }

    //tell calling code that they should watch for this event
    public EventHander GotResponse
    //indicate to calling code that they can go and do something else for a bit
    public EventHander YouCanWait;
}

这是信息这两个位调用代码,以运行异步方式的代码之类的东西需要Taskasync封装。

进行异步功能的方法不止一种,async Task只是通过返回类型在编译器中内置的一种模式,因此您不必手动链接事件


0

我将以较少的C#ness方式和更通用的方式解决要点:

异步/等待与“良好架构”原则相抵触的最佳做法吗?

我要说的是,这仅取决于您在API设计中所做的选择以及您向用户提供的内容。

如果您希望API的一个功能仅是异步的,那么遵循命名约定就没有什么兴趣了。只是总是返回Task <> / Promise <> / Future <> / ...作为返回类型,这是自我记录。如果想要一个同步的答案,他仍然可以通过等待来完成,但是如果他总是这样做,这会有些重复。

但是,如果仅使API同步,则意味着如果用户希望它异步,则他必须自己管理它的异步部分。

这可以做很多额外的工作,但是,它还可以使用户更好地控制他所允许的并发呼叫数量,发出超时,重试等等。

在具有庞大API的大型系统中,与独立管理API的每个部分(尤其是共享资源(文件系统,CPU,数据库等)的情况)相比,默认情况下将大多数实现默认情况下进行同步可能更容易,更高效。

实际上,对于最复杂的部分,您可以完美地对API的同一部分进行两种实现,一种是同步处理方便的事情,一种异步依赖于同步处理一件事情,并且仅管理并发,负载,超时和重试。

也许其他人可以分享他的经验,因为我缺乏此类系统的经验。


2
@Miral在两种可能性中都使用了“从同步方法调用异步方法”。
Adrian Wragg

@AdrianWragg所以我做到了;我的大脑一定有种族状况。我会解决的。
Miral

相反。从sync方法中调用async方法很简单,但是从async方法中调用sync方法是不可能的。(而且,如果有人尝试执行后者,事情就会完全崩溃,这可能会导致死锁。)因此,如果您必须选择一个,则默认情况下异步是更好的选择。不幸的是,这也是较困难的选择,因为异步实现只能调用异步方法。
Miral

(这当然意味着阻塞同步方法。您可以从异步方法中调用可以同步进行纯CPU绑定计算的内容-尽管除非您知道自己处于工作环境中,否则应尽量避免这样做而不是UI上下文-但是阻止在锁,I / O或其他操作上等待空闲的调用是个坏主意。)
Miral
By using our site, you acknowledge that you have read and understand our Cookie Policy and Privacy Policy.
Licensed under cc by-sa 3.0 with attribution required.