异步方法返回已完成的任务异常缓慢

时间:2019-08-23 08:09:15

标签: c# .net async-await

我有一些可以在Web服务器上正常运行的C#代码。该代码使用async / await,因为它在生产环境中执行一些网络调用。

我还需要对代码进行一些模拟;在仿真过程中,该代码同时被调用数十亿次。模拟不会执行任何网络调用:使用了一个模拟,该模拟使用Task.FromResult()返回一个值。模拟返回的值实际上模拟了在生产环境中可以接收到的网络调用的所有可能响应。

我无法理解使用async / await会有一些开销,但是我也希望在性能上不会有太大的区别,因为已经返回了已经完成的任务并且应该没有实际的等待。

但是进行一些测试后,我发现性能大大下降了(尤其是在某些硬件上)。

我在启用编译器优化的情况下使用LinqPad测试了以下代码;如果要直接在Visual Studio中进行测试,则可以删除.Dump()调用并将代码粘贴到控制台应用程序中。

// SYNC VERSION

void Main()
{
    Enumerable.Range(0, 1_000_000_000)
        .AsParallel()
        .Aggregate(
            () => 0.0,
            (a, i) => Calc(a, i),
            (a1, a2) => a1 + a2,
            f => f
        )
        .Dump();
}

double Calc(double a, double i) => a + Math.Sin(i);

// ASYNC-AWAIT VERSION

void Main()
{
    Enumerable.Range(0, 1_000_000_000)
        .AsParallel()
        .Aggregate(
            () => 0.0,
            (a, i) => Calc(a, i).Result,
            (a1, a2) => a1 + a2,
            f => f
        )
        .Dump();
}


async Task<double> Calc(double a, double i) => a + Math.Sin(i);

该代码的异步等待版本体现了我的模拟代码的情况。

我在i7机器上非常成功地运行了仿真。但是,当我尝试在我们办公室的AMD ThreadRipper计算机上运行代码时,结果却很糟糕。

我已经在i7机器和AMD ThreadRipper上使用linq pad中的上述代码运行了一些基准测试,结果如下:

TEST on i7 quad-core 3,67 Ghz (windows 10 pro x64):

sync version: 15 sec (100% CPU)
async-await version: 20 sec (93% CPU)
TEST on AMD 32 cores 3,00 Ghz (windows server 2019 x64):

sync version: 16 sec (50% CPU)
async-await version: 140 sec (14% CPU)

我了解硬件存在差异(也许Intel超线程更好,等等),但是这个问题与硬件性能无关。

为什么不是总是有100%的CPU使用率(或考虑到CPU超线程的最坏情况才有50%),但是异步等待版本的CPU使用率却下降了?

(CPU使用率的下降在AMD上更为明显,但在Intel上也存在)

是否有不涉及代码周围所有异步等待调用重构的变通办法? (代码库又大又复杂)

谢谢。

编辑

正如评论中建议的那样,我尝试使用ValueTask插入的Task,看来可以解决此问题。我直接在VS中尝试了此操作,因为我需要一个nuget程序包(发布版本),结果如下:

TEST on i7

"sync" version: 16 sec (100% CPU)
"await Task" version: 49 sec (95% CPU)
"await ValueTask" version: 31 sec (100% CPU)

TEST on AMD

"sync" version: 15 sec (50% CPU)
"await Task" version: 125 sec (12% CPU)
"await ValueTask" version: 17 sec (50% CPU)

老实说,我对ValueTask类了解不多,我将对其进行研究。如果您可以解释/详细说明答案,那么欢迎您。

谢谢。

2 个答案:

答案 0 :(得分:3)

您的垃圾收集器很可能配置为工作站模式(默认),该模式使用单个线程回收未使用对象分配的内存。对于具有32核的机器,一个核肯定不足以清除其余31核不断产生的混乱!因此,您可能应该切换到server mode

<configuration>
  <runtime>
    <gcServer enabled="true"></gcServer>
  </runtime>
</configuration>
  

后台服务器垃圾回收使用多个线程,通常是每个逻辑处理器专用的线程。

通过使用ValueTask而不是Task,可以避免在堆中分配内存,因为ValueTask是在堆栈中分配的结构,不需要垃圾回收。但这仅在包装完成任务的结果时才是这种情况。如果它包装了一个不完整的任务,那么它就没有优势。它适用于您必须await进行数千万个任务,并且您希望其中的绝大多数将完成的情况。

答案 1 :(得分:2)

我想解决这个问题:

  

代码的异步等待版本体现了我的生产代码的情况。

您说您的生产版本“执行一些网络调用”。如果真是这样,那么您在此处显示的代码并不代表您的生产代码。 Lasse在评论中提到了原因:您的async方法不是异步运行的。原因在于await的工作方式。

await关键字查看您所调用的方法返回的Task。您知道它将暂停该方法的执行并注册该方法的其余部分,作为Task的延续。但是您可能不知道的是,只有在Task尚未完成时才会发生。如果Task看着await已经完成,那么您的代码将同步进行。实际上,您应该看到一个编译器警告告诉您:

  

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

因此,两个代码块之间的唯一区别是您的async版本只是增加了await的不必要开销,以便仍然可以同步运行。

要拥有真正的异步方法,您实际上必须做一些需要等待的事情。如果要模拟此情况,可以使用Task.Delay。即使您使用可能有的最小延迟(Task.Delay(TimeSpan.FromTicks(1))),它仍然会触发await来完成工作。

async Task<double> Calc(double a, double i)
{
    await Task.Delay(TimeSpan.FromTicks(1));
    return a + Math.Sin(i);
}

那当然会引入您以前没有的延迟,因此您应该将其与使用Thread.Sleep且持续时间相同的同步版本进行比较:

double Calc(double a, double i)
{
    Thread.Sleep(TimeSpan.FromTicks(1));
    return a + Math.Sin(i);
}

在我的Intel Core i7上,异步版本运行约22秒,而同步版本运行约50秒。

通常我会说,当您使用.Result时,异步代码的所有好处都会被抛在窗外,但是您使用的是AsParallel() ...但是我仍然不确定如何处理影响性能。