接口的同步实现,返回任务

9
类似于在同步代码中实现需要Task返回类型的接口,尽管我很好奇是否应该忽略编译器错误而不理会我的情况。
假设我有这样一个接口:
public interface IAmAwesome {
    Task MakeAwesomeAsync();
}

在某些实现中,使用asyncawait异步地完成任务可以带来很多好处。这正是接口所试图实现的。

在其他情况下(也许很少见),只需使用简单的同步方法即可实现出色的效果。因此,假设实现看起来像这样:

public class SimplyAwesome : IAmAwesome {
    public async Task MakeAwesomeAsync() {
        // Some really awesome stuff here...
    }
}

这个方法可以运行,但是编译器会发出警告:

该方法缺少“await”运算符,因此将同步运行。考虑使用“await”运算符等待非阻塞API调用, 或使用“await TaskEx.Run(...)”在后台线程上执行CPU绑定的工作。

编译器实际上建议采用以下解决方案:

public class SimplyAwesome : IAmAwesome {
    public async Task MakeAwesomeAsync() {
        await Task.Run(() => {
            // Some really awesome stuff here, but on a BACKGROUND THREAD! WOW!
        });
    }
}

我的问题是 - 在什么情况下我应该忽略这个编译器警告?在某些情况下,工作非常简单,为其生成一个线程显然是适得其反的。


2
你"应该"定义一个 public interface IAmAwesome { Task MakeAwesomeAsync(); void MakeAwesome(); } 接口来公开异步和同步方法。但是,这是否适合你的实际情况则是另一回事。 - Scott Chamberlain
我猜问题是为什么你要创建不涉及任何异步操作的任务。你能提供一个具体的用例吗? - JLRishe
2
@JLRishe 一个常见的例子是执行某些可以被缓存的操作。从缓存中获取的版本可以是同步的,而缓存未命中的版本则可以是异步的。 - Servy
不过不深入探讨,以我的情况为例,这个例子是获取价格。有时它只涉及简单的数学计算(即CPU绑定),而在其他情况下则涉及昂贵的API和数据库调用。 - Yuck
我会测量计算的成本。如果它相当快,那么将结果包装在“任务”中应该是可以的。 - Yuval Itzchakov
3个回答

13
如果您真的想要同步地执行工作,并且您知道您的async方法将始终同步运行,并且这在某些情况下是理想的,那么请无视此警告。如果您理解此警告所描述的操作并认为其是正确的,则没有问题。毕竟它是警告而不是错误,理由是有原因的。
当然,另一个选择是不使方法成为async方法,而是使用Task.FromResult返回一个已完成的任务。这将改变错误处理语义(除非您也捕获所有异常并将它们封装到返回的任务中),因此请至少注意一下。如果您真的希望异常通过结果Task传播,那么保持方法为async可能是值得的,只需抑制警告即可。

如果我忽略警告,客户端无意中使用await调用该方法,那么任何异常会发生什么? - Yuck
@Yuck 返回的 Task 将会是一个 Faulted 状态并且包含有关异常的信息。 当这个 Task 被等待时,该异常将被重新抛出。 它看起来与操作实际上是异步的没有任何区别。 - Servy
为什么不保留 async,然后通过执行 await Task.FromResult 或者现在的 await Task.CompletedTask 来消除编译器警告呢?这样做不仅可以消除编译器警告,还可以保持消费者期望的异常处理语义,这不是更好吗? - rory.ap
@rory.ap 我不明白有意将结果包装在已完成的任务中,然后再次取消包装,如何比不这样做更好地表达代码意图。我提到的第二个选项是所有选项中唯一真正表达明确意图以同步方式运行方法并返回已完成任务的选项。 - Servy

7
当我选择忽略这个编译器警告时,应该考虑什么因素呢?在某些情况下,工作非常简单,为其生成线程确实是低效的。
编译器并没有说“在此方法中使用Task.Run”。它只是告诉你,在方法声明中添加async修饰符,但你实际上没有等待任何内容。
你可以采取三种方法:
A. 忽略编译器警告,所有内容仍将执行。请注意,会有状态机生成的轻微开销,但是您的方法调用将同步执行。如果操作耗时,并可能导致方法执行进行阻塞调用,则可能会使使用此方法的用户感到困惑。
B. 将“Awesome”生成分离为同步接口和异步接口:
public interface MakeAwesomeAsync
{
    Task MakeAwesomeAsync();
}

public interface MakeAwesome
{
    void MakeAwesome();
}

C. 如果操作不太耗时,您可以使用Task.FromResult将其简单包装在Task中。在选择此方法之前,一定要测量运行CPU绑定操作所需的时间。


这可能会让你的代码使用者感到困惑。只有当操作时间较长时才是真的。如果操作可以非常快速地完成,那么这不是问题。例如,文件IO的“Stream”实现同步执行其大部分“异步”方法,因为它所需的时间非常短,不值得费力去异步执行,但网络IO实现需要异步执行,因为它们需要花费足够长的时间。 - Servy
@Servy,我不太确定您所说的样板文件是什么意思。您能详细说明一下吗? - Yuval Itzchakov
1
@YuvalItzchakov 你需要捕获所有异常,然后使用该异常返回一个故障任务。如果不这样做,异常将传播到方法的调用者,而不是在生成的“Task”中返回。 - Servy
1
@YuvalItzchakov 这里的状态机开销实际上为您提供了一些东西;您正在使用它。但是,是的,您需要与其他程序员合作,他们了解为什么要编写这样的方法。消费者不需要关心,但是其他开发人员需要关心。 - Servy
从消费者的角度来看,它们是相同的。但从实现的角度来看,它们是不同的。 - Yuval Itzchakov
显示剩余10条评论

7

正如其他人已经指出的那样,你有3个不同的选择,所以这是一个观点问题:

  1. 保持async并忽略警告
  2. 拥有同步/async重载
  3. 删除async并返回一个已完成的任务。

我建议返回一个已完成的任务

public class SimplyAwesome : IAmAwesome 
{
    public Task MakeAwesomeAsync() 
    {
        // run synchronously
        return TaskExtensions.CompletedTask;
    }
}

由于以下几个原因:
  • 将方法标记为async会稍微增加一些开销,例如创建状态机和禁用一些优化(如内联),因为编译器会添加try-catch块。
  • 您需要处理警告以及其他团队成员查看此代码时想知道await在哪里的情况。
  • 将完全同步的方法标记为async有点不协调。这就像在代码中添加while(false){},它的行为相同,但它没有传达方法的含义。

Servy指出返回任务会改变异常处理的语义。虽然这是正确的,但我认为这不是问题。

首先,大多数async代码在同一个位置调用方法并等待返回的任务(即await MakeAwesomeAsync()),这意味着无论方法是否async,异常都将在同一个位置抛出。

其次,即使是.Net框架返回任务的方法也会同步抛出异常。例如,Task.Delay直接抛出异常而不将其存储在返回的任务中,因此无需await任务来引发异常。
try
{
    Task.Delay(-2);
}
catch (Exception e)
{
    Console.WriteLine(e);
}

.Net开发人员需要在.Net中同步遇到异常,因此他们也应该从您的代码中期望这种情况。


1
我不确定为什么这个答案没有更多的赞。在我看来,对于一个不运行异步操作的方法,不使用async关键字是最有意义的选择。在API中,返回类型为Task只是意味着该方法可能会异步运行,而不是保证一定会异步运行。方法的实现者可以自行决定是否采用异步实现方式,而不会影响公共API。 - jam40jeff
1
此外,为.NET 4.6赢得了双倍的赞誉,因为TaskExtensions.CompletedTask比我一直在做的Task.FromResult(0)更具性能(并且传达意义更好)。 - jam40jeff

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接