当必须同时具有异步和同步版本的代码时,如何避免违反DRY原理?


15

我正在一个需要同时支持异步和同步版本的同一逻辑/方法的项目。因此,例如,我需要:

public class Foo
{
   public bool IsIt()
   {
      using (var conn = new SqlConnection(DB.ConnString))
      {
         return conn.Query<bool>("SELECT IsIt FROM SomeTable");
      }
   }

   public async Task<bool> IsItAsync()
   {
      using (var conn = new SqlConnection(DB.ConnString))
      {
         return await conn.QueryAsync<bool>("SELECT IsIt FROM SomeTable");
      }
   }
}

这些方法的异步和同步逻辑在各个方面都相同,除了一个是异步的,而另一个不是。在这种情况下,是否有合法的方法避免违反DRY原则?我看到有人说您可以在异步方法上使用GetAwaiter()。GetResult()并从您的sync方法中调用它?在所有情况下该线程都安全吗?还有其他更好的方法可以做到这一点,还是我不得不重复逻辑?


1
您能多说点什么吗?具体来说,如何在异步方法中实现异步?是高延迟工作CPU绑定还是IO绑定?
埃里克·利珀特

“一个是异步的,而另一个不是”是实际方法的代码还是仅接口的区别?(显然,您只需要提供界面即可return Task.FromResult(IsIt());
Alexei Levenkov

另外,大概同步和异步版本都是高延迟的。在什么情况下,调用方将使用同步版本,并且该调用方将如何缓解被调用方可能花费数十亿纳秒的事实?同步版本的调用者是否不在乎他们是否挂起了UI?没有UI吗?告诉我们更多有关该场景的信息。
埃里克·利珀特

@EricLippert我添加了一个特定的虚拟示例,目的只是让您了解2个代码库是如何相同的,而不是一个异步的事实。您可以轻松地想象出这种方法更加复杂并且必须重复执行多行代码的情况。
Marko

@AlexeiLevenkov的区别确实在代码上,我添加了一些虚拟代码对其进行演示。
Marko

Answers:


15

您在问题中问了几个问题。我将把它们分解成与您稍有不同的方法。但是首先让我直接回答这个问题。

我们都想要一台轻巧,高质量和便宜的相机,但是俗话说,这三者中最多只能有两个。您在这里处于相同情况。您需要一个高效,安全并且在同步和异步路径之间共享代码的解决方案。您只会得到其中两个。

让我解释一下为什么。我们将从这个问题开始:


我看到有人说您可以GetAwaiter().GetResult()在异步方法上使用并从同步方法中调用它?在所有情况下该线程都安全吗?

这个问题的重点是“我是否可以通过使同步路径仅在异步版本上进行同步等待来共享同步和异步路径?”

在这一点上,让我非常清楚,因为它很重要:

您应该立即停止从这些人那里获得任何建议

那是非常糟糕的建议。除非您有证据表明任务已正常完成或异常完成,否则从异步任务中同步获取结果是非常危险的。

这是非常糟糕的建议,原因是考虑这种情况。您想割草,但是割草机刀片坏了。您决定遵循以下工作流程:

  • 从网站订购新刀片。这是一个高延迟的异步操作。
  • 同步等待-即入睡,直到手握刀片
  • 定期检查邮箱,以查看刀片是否到达。
  • 从包装盒中取出刀片。现在您已经掌握了它。
  • 将刀片安装在割草机中。
  • 修剪草坪。

怎么了?您将永远睡不着,因为现在检查邮件的操作被限制在邮件到达后发生的事情上

这是非常容易陷入这种情况,当你同步等待上的任意任务。该任务可能在正在等待的线程的将来安排了工作,而由于您正在等待它,所以将来永远也不会到达。

如果您执行异步等待,那么一切都很好!您定期检查邮件,而在等待期间,您做三明治或纳税或其他任何事情;您可以在等待时继续完成工作。

切勿同步等待。如果任务完成,则没有必要。如果任务未完成但计划在当前线程上运行,则效率低下,因为当前线程可能正在为其他工作提供服务,而不是等待。如果任务未完成,并且调度在当前线程上运行,则挂起它以同步等待。没有充分的理由再次进行同步等待,除非您已经知道任务已完成

有关此主题的更多信息,请参见

https://blog.stephencleary.com/2012/07/dont-block-on-async-code.html

斯蒂芬比我能更好地解释现实情况。


现在让我们考虑“其他方向”。我们是否可以通过使异步版本仅在工作线程上执行同步版本来共享代码?

由于以下原因,这可能是并且确实可能是一个坏主意。

  • 如果同步操作是高延迟的IO工作,则效率很低。从本质上讲,这雇用了一名工人,并使该工人入睡直至完成任务。线程异常昂贵。默认情况下,它们最少占用一百万字节的地址空间,它们会花费时间,并占用操作系统资源。您不想烧掉线程做无用的工作。

  • 同步操作可能不是写为线程安全的。

  • 如果高延迟工作受处理器限制,则这一种更合理的技术,但是如果是这样,那么您可能不希望将其交给工作线程。您可能想要使用任务并行库将其并行化到尽可能多的CPU,您可能想要取消逻辑,并且不能简单地使同步版本执行所有操作,因为那样的话,它已经是异步版本了

进一步阅读;斯蒂芬再次非常清楚地解释了这一点:

为什么不使用Task.Run:

https://blog.stephencleary.com/2013/11/taskrun-etiquette-examples-using.html

Task.Run的更多“做与不做”方案:

https://blog.stephencleary.com/2013/11/taskrun-etiquette-examples-dont-use.html


那给我们留下了什么呢?两种共享代码的技术都会导致死锁或效率低下。我们得出的结论是,您必须做出选择。您是否想要一个高效,正确并能使调用者满意的程序,还是想节省一些通过在同步和异步路径之间复制少量代码而引起的击键?恐怕你们不会两者兼得。


您能解释一下为什么Task.Run(() => SynchronousMethod())异步版本不是OP想要的吗?
Guillaume Sasdy

2
@GuillaumeSasdy:原始海报回答了工作是什么的问题:它是IO约束的。在工作线程上运行IO绑定的任务很浪费!
埃里克·利珀特

好吧,我明白了。我认为您是对的,@ StriplingWarrior的答案也可以进一步说明。
Guillaume Sasdy

2
@Marko几乎所有阻塞线程的解决方法最终(在高负载下)最终都会消耗所有线程池线程(通常用于完成异步操作)。结果,所有线程将处于等待状态,并且没有任何线程可让操作运行代码的“异步操作完成”部分。在低负载情况下,大多数变通办法都很好(因为每个单个操作的一部分都有2-3个线程被阻塞,所以有很多线程)…并且如果您可以保证异步操作的完成在新的OS线程(而不是线程池)上运行,甚至可能在所有情况下都可以工作(您为此付出了高昂的代价)
Alexei Levenkov

2
有时候,除了“仅在工作线程上简单地执行同步版本”之外,实际上没有其他方法。例如,这Dns.GetHostEntryAsync就是在.NET中以及FileStream.ReadAsync某些类型的文件中实现的方式。操作系统仅不提供异步接口,因此运行时必须伪造它(并且它不是特定于语言的-例如,Erlang运行时运行整个工作进程树,每个工作树中都有多个线程,以提供无阻塞的磁盘I / O和名称解析度)。
Joker_vD

6

对此很难给出一刀切的答案。不幸的是,没有简单,完美的方法来实现异步和同步代码之间的重用。但是,这里有一些要考虑的原则:

  1. 异步和同步代码通常在根本上是不同的。例如,异步代码通常应包含取消令牌。通常,它最终会调用不同的方法(例如您的示例Query()在一个方法中调用另一个方法QueryAsync()),或者使用不同的设置来建立连接。因此,即使在结构上相似,行为上也经常存在足够的差异,值得将它们视为具有不同要求的单独代码。请注意File类中方法的AsyncSync实现之间的区别,例如:不会使它们使用相同的代码
  2. 如果您是为了实现接口而提供异步方法签名,但恰好有一个同步实现(即,您的方法本质上没有异步),则可以简单地返回Task.FromResult(...)
  3. 逻辑的任何同步块,其这两种方法之间的相同,可以提取到一个单独的辅助方法和这两种方法利用。

祝好运。


-2

简单; 让同步一调用异步一。甚至还有一个方便的方法Task<T>可以做到这一点:

public class Foo
{
   public bool IsIt()
   {
      var task = IsItAsync(); //no await here; we want the Task

      //Some tasks end up scheduled to run before you get them;
      //don't try to run them a second time
      if((int)task.Status > (int)TaskStatus.Created)
          //this call will block the current thread,
          //and unlike Run()/Wait() will prefer the current 
          //thread's TaskScheduler instead of a new thread.
          task.RunSynchronously(); 

      //if IsItAsync() can throw exceptions,
      //you still need a Wait() call to bring those back from the Task
      try{ 
          task.Wait();
          return task.Result;
      }
      catch(Exception ex) 
      { 
          //Handle IsItAsync() exceptions here;
          //remember to return something if you don't rethrow              
      }
   }

   public async Task<bool> IsItAsync()
   {
      // Some async logic
   }
}

1
请参阅我的答案,以了解为什么这是您绝不能做的最坏的做法。
埃里克·利珀特

1
您的答案涉及GetAwaiter()。GetResult()。我避免了。现实情况是,有时必须使方法同步运行才能在无法进行异步处理的调用堆栈中使用它。如果永远不要执行同步等待,那么作为C#团队的(前)成员,请告诉我为什么不将一种方法同步地等待TPL中的异步任务,而是采用两种方法。
KeithS

问这个问题的人是斯蒂芬·图布,而不是我。我只是在语言方面工作。
埃里克·利珀特
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.