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

Mar*_*rko 16 .net c# asynchronous

我正在开发一个需要同时支持同一逻辑/方法的异步和同步版本的项目。所以例如我需要有:

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");
      }
   }
}
Run Code Online (Sandbox Code Playgroud)

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

Eri*_*ert 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


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

  • @GuillaumeSasdy:原始海报已经回答了工作是什么的问题:它是 IO 绑定的。在工作线程上运行 IO 密集型任务是一种浪费! (2认同)
  • @Marko几乎任何阻塞线程的解决方法最终都会(在高负载下)消耗所有线程池线程(通常用于完成异步操作)。结果,所有线程都将等待,并且没有一个线程可用于让操作运行代码的“异步操作已完成”部分。大多数解决方法在低负载场景下都很好(因为有很多线程,甚至 2-3 个线程作为每个单个操作的一部分被阻塞)......并且如果您可以保证异步操作的完成在新的操作系统线程(而不是线程池)上运行甚至可能适用于所有情况(所以你要付出高昂的代价) (2认同)
  • 有时,除了“简单地在工作线程上执行同步版本”之外,确实没有其他方法。例如,这就是在 .NET 中实现“Dns.GetHostEntryAsync”的方式,以及针对某些类型的文件实现“FileStream.ReadAsync”的方式。操作系统只是不提供异步接口,因此运行时必须伪造它(并且它不是特定于语言的,例如,Erlang 运行时运行整个工作进程树,每个进程内部有多个线程,以提供非阻塞磁盘 I/O和名称解析)。 (2认同)

Str*_*ior 6

很难对这个问题给出一个千篇一律的答案。不幸的是,没有一种简单、完美的方法可以在异步和同步代码之间实现重用。但这里有一些原则需要考虑:

  1. 异步和同步代码通常是根本不同的。例如,异步代码通常应包含取消标记。通常它最终会调用不同的方法(如您的示例调用Query()一种方法和QueryAsync()另一种方法),或者使用不同的设置建立连接。因此,即使它在结构上相似,通常也有足够的行为差异,值得将它们视为具有不同要求的单独代码。注意File 类中方法的AsyncSync实现之间的差异,例如:没有努力使它们使用相同的代码
  2. 如果您为了实现接口而提供异步方法签名,但您碰巧有一个同步实现(即,您的方法所做的事情没有本质上的异步),您可以简单地 return Task.FromResult(...)
  3. 两种方法之间相同的任何同步逻辑可以提取到单独的辅助方法中,并在两种方法中使用。

祝你好运。